From 5d200994b54d11ea4a802b352aba915816b6f50a Mon Sep 17 00:00:00 2001 From: mariiaKraievska Date: Fri, 20 Mar 2026 16:30:13 +0200 Subject: [PATCH 1/3] FINERACT-2455: WC - Loan account Disbursement / Undo --- .../service/CommandWrapperBuilder.java | 16 + .../module/fineract-provider/persistence.xml | 3 + .../WorkingCapitalLoanConstants.java | 11 + .../api/WorkingCapitalLoanApiResource.java | 8 +- .../WorkingCapitalLoanApiResourceSwagger.java | 52 +- ...ingCapitalLoanTransactionsApiResource.java | 103 +- ...talLoanTransactionsApiResourceSwagger.java | 115 ++ .../data/WorkingCapitalLoanData.java | 1 + .../WorkingCapitalLoanTransactionData.java | 54 + ...pitalLoanTransactionPaymentDetailData.java | 41 + .../domain/WorkingCapitalLoan.java | 5 + .../domain/WorkingCapitalLoanEvent.java | 4 +- ...rkingCapitalLoanLifecycleStateMachine.java | 6 + .../domain/WorkingCapitalLoanTransaction.java | 117 ++ ...rkingCapitalLoanTransactionAllocation.java | 71 ++ ...ngCapitalLoanTransactionPaymentDetail.java | 59 + ...pitalLoanTransactionNotFoundException.java | 39 + ...burseWorkingCapitalLoanCommandHandler.java | 42 + ...burseWorkingCapitalLoanCommandHandler.java | 42 + .../mapper/WorkingCapitalLoanMapper.java | 6 +- .../WorkingCapitalLoanTransactionMapper.java | 60 + .../WorkingCapitalLoanBalanceRepository.java | 28 + .../WorkingCapitalLoanRepository.java | 6 + ...alLoanTransactionAllocationRepository.java | 24 + ...oanTransactionPaymentDetailRepository.java | 25 + ...rkingCapitalLoanTransactionRepository.java | 40 + .../WorkingCapitalLoanDataValidator.java | 176 +++ ...lLoanAmortizationScheduleWriteService.java | 7 + ...nAmortizationScheduleWriteServiceImpl.java | 78 +- ...talLoanTransactionReadPlatformService.java | 33 + ...oanTransactionReadPlatformServiceImpl.java | 87 +- ...orkingCapitalLoanWritePlatformService.java | 4 + ...ngCapitalLoanWritePlatformServiceImpl.java | 222 ++++ .../module-changelog-master.xml | 2 + .../parts/0011_wc_loan_transaction.xml | 282 +++++ .../0012_wc_loan_disbursement_permissions.xml | 56 + .../persistence.xml | 5 + ...rkingCapitalLoanTransactionMapperTest.java | 106 ++ .../WorkingCapitalLoanDisbursementTest.java | 1086 +++++++++++++++++ .../WorkingCapitalLoanApplicationHelper.java | 79 +- ...ingCapitalLoanDisbursementTestBuilder.java | 108 ++ 41 files changed, 3287 insertions(+), 22 deletions(-) create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanTransactionNotFoundException.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DisburseWorkingCapitalLoanCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoDisburseWorkingCapitalLoanCommandHandler.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBalanceRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0011_wc_loan_transaction.xml create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0012_wc_loan_disbursement_permissions.xml create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 93c21a0531f..4e983bdce80 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -597,6 +597,22 @@ public CommandWrapperBuilder undoWorkingCapitalLoanApplicationApproval(final Lon return this; } + public CommandWrapperBuilder disburseWorkingCapitalLoanApplication(final Long loanId) { + this.actionName = "DISBURSE"; + this.entityName = "WORKINGCAPITALLOAN"; + this.entityId = loanId; + this.href = "/workingcapitalloans/" + loanId; + return this; + } + + public CommandWrapperBuilder undoWorkingCapitalLoanApplicationDisbursal(final Long loanId) { + this.actionName = "DISBURSALUNDO"; + this.entityName = "WORKINGCAPITALLOAN"; + this.entityId = loanId; + this.href = "/workingcapitalloans/" + loanId; + return this; + } + public CommandWrapperBuilder createClientIdentifier(final Long clientId) { this.actionName = "CREATE"; this.entityName = "CLIENTIDENTIFIER"; diff --git a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml index e509785ddfb..e85b7b9a7ac 100644 --- a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml +++ b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml @@ -307,6 +307,9 @@ org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPaymentAllocationRule + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductPaymentAllocationRule diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java index 2b96ea695de..21ff74e161a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java @@ -55,4 +55,15 @@ private WorkingCapitalLoanConstants() { public static final String discountAmountParamName = "discountAmount"; public static final String noteParamName = "note"; public static final String rejectedOnDateParamName = "rejectedOnDate"; + + // Disbursal / Undo disbursal parameters + public static final String actualDisbursementDateParamName = "actualDisbursementDate"; + public static final String transactionAmountParamName = "transactionAmount"; + public static final String paymentDetailsParamName = "paymentDetails"; + public static final String paymentTypeIdParamName = "paymentTypeId"; + public static final String accountNumberParamName = "accountNumber"; + public static final String checkNumberParamName = "checkNumber"; + public static final String routingCodeParamName = "routingCode"; + public static final String receiptNumberParamName = "receiptNumber"; + public static final String bankNumberParamName = "bankNumber"; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java index 68cb75af5ec..e4c47400d39 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java @@ -196,7 +196,7 @@ public CommandProcessingResult deleteLoanApplication( @Path("{loanId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "stateTransitionWorkingCapitalLoanById", summary = "Approve/Reject/Undo-approve a Working Capital Loan", description = "Mandatory command query parameter: approve, reject, or undoapproval.") + @Operation(operationId = "stateTransitionWorkingCapitalLoanById", summary = "Approve/Reject/Undo-approve/Disburse/Undo-disburse a Working Capital Loan", description = "Mandatory command query parameter: approve, reject, undoapproval, disburse, or undodisbursal.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdResponse.class))) }) @@ -211,7 +211,7 @@ public CommandProcessingResult stateTransitionById( @Path("external-id/{loanExternalId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "stateTransitionWorkingCapitalLoanByExternalId", summary = "Approve/Reject/Undo-approve a Working Capital Loan by external id", description = "Mandatory command query parameter: approve, reject, or undoapproval.") + @Operation(operationId = "stateTransitionWorkingCapitalLoanByExternalId", summary = "Approve/Reject/Undo-approve/Disburse/Undo-disburse a Working Capital Loan by external id", description = "Mandatory command query parameter: approve, reject, undoapproval, disburse, or undodisbursal.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdResponse.class))) }) @@ -261,6 +261,10 @@ private CommandProcessingResult handleStateTransition(final Long loanId, final S commandRequest = builder.rejectWorkingCapitalLoanApplication(resolvedLoanId).build(); } else if (CommandParameterUtil.is(commandParam, "undoapproval")) { commandRequest = builder.undoWorkingCapitalLoanApplicationApproval(resolvedLoanId).build(); + } else if (CommandParameterUtil.is(commandParam, "disburse")) { + commandRequest = builder.disburseWorkingCapitalLoanApplication(resolvedLoanId).build(); + } else if (CommandParameterUtil.is(commandParam, "undodisbursal")) { + commandRequest = builder.undoWorkingCapitalLoanApplicationDisbursal(resolvedLoanId).build(); } if (commandRequest == null) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java index 18b987b2787..86140f6e900 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java @@ -197,6 +197,8 @@ private GetWorkingCapitalLoansLoanIdResponse() {} public List disbursementDetails; /** Running balances (principal outstanding, total payment, etc.). */ public GetBalance balance; + @Schema(description = "Transaction history (e.g. disbursement).") + public List transactions; } @Schema(description = "Working capital loan running balances") @@ -244,6 +246,19 @@ private GetPaymentAllocation() {} public List paymentAllocationOrder; } + @Schema(description = "Loan transaction type enum data (same as basic loan)") + public static final class LoanTransactionEnumData { + + private LoanTransactionEnumData() {} + + @Schema(example = "1") + public Long id; + @Schema(example = "loanTransactionType.disbursement") + public String code; + @Schema(example = "Disbursement") + public String value; + } + @Schema(description = "GetPaymentAllocationOrder") public static final class GetPaymentAllocationOrder { @@ -260,9 +275,9 @@ public static final class PostWorkingCapitalLoansRequest { private PostWorkingCapitalLoansRequest() {} - @Schema(example = "1", required = true) + @Schema(example = "1", requiredMode = Schema.RequiredMode.REQUIRED) public Long clientId; - @Schema(example = "1", required = true) + @Schema(example = "1", requiredMode = Schema.RequiredMode.REQUIRED) public Long productId; @Schema(example = "1") public Long fundId; @@ -270,7 +285,7 @@ private PostWorkingCapitalLoansRequest() {} public String accountNo; @Schema(example = "ext-id-001") public String externalId; - @Schema(example = "10000.00", required = true, description = "Principal (disbursement) amount") + @Schema(example = "10000.00", requiredMode = Schema.RequiredMode.REQUIRED, description = "Principal (disbursement) amount") public BigDecimal principalAmount; @Schema(example = "10500.00") public BigDecimal totalPayment; @@ -335,6 +350,25 @@ private PostWorkingCapitalLoansResponse() {} public Long loanId; } + @Schema(description = "Payment details for disbursement (Account No, Cheque No, Routing Code, Receipt No, Bank code)") + public static final class PostWorkingCapitalLoansLoanIdDisbursementPaymentDetails { + + private PostWorkingCapitalLoansLoanIdDisbursementPaymentDetails() {} + + @Schema(example = "1", description = "Payment type id") + public Integer paymentTypeId; + @Schema(example = "acc123", description = "Account No") + public String accountNumber; + @Schema(example = "che123", description = "Cheque No") + public String checkNumber; + @Schema(example = "rou123", description = "Routing Code") + public String routingCode; + @Schema(example = "rec123", description = "Receipt No") + public String receiptNumber; + @Schema(example = "ban123", description = "Bank code") + public String bankNumber; + } + @Schema(description = "PutWorkingCapitalLoansLoanIdRequest") public static final class PutWorkingCapitalLoansLoanIdRequest { @@ -416,7 +450,7 @@ private PostWorkingCapitalLoansLoanIdResponse() {} public Object changes; } - @Schema(description = "PostWorkingCapitalLoansLoanIdRequest") + @Schema(description = "Request for state transition: approve, reject, undoapproval, disburse, undodisbursal") public static final class PostWorkingCapitalLoansLoanIdRequest { private PostWorkingCapitalLoansLoanIdRequest() {} @@ -431,11 +465,19 @@ private PostWorkingCapitalLoansLoanIdRequest() {} public BigDecimal discountAmount; @Schema(example = "15 January 2024", description = "Date of rejection") public String rejectedOnDate; - @Schema(example = "Approval/Rejection note") + @Schema(example = "Approval/Rejection/Disbursal Note") public String note; @Schema(example = "en_GB") public String locale; @Schema(example = "dd MMMM yyyy") public String dateFormat; + @Schema(example = "28 June 2024", description = "Required for disburse - Actual Disbursement date") + public String actualDisbursementDate; + @Schema(example = "1000", description = "Disbursement amount; required for disburse. Cannot exceed approved principal.") + public BigDecimal transactionAmount; + @Schema(example = "ext-disburse-001", description = "External ID; optional for disburse") + public String externalId; + @Schema(description = "Payment details (Account No, Cheque No, Routing Code, Receipt No, Bank code)") + public PostWorkingCapitalLoansLoanIdDisbursementPaymentDetails paymentDetails; } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java index d0cbee00528..995a6bbf177 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java @@ -20,6 +20,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; @@ -31,27 +35,116 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.UriInfo; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.jersey.Pagination; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCommandTemplateData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanApplicationReadPlatformService; import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanTransactionReadPlatformService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @Component @Path("/v1/working-capital-loans") -@Tag(name = "Working Capital Loan Transactions", description = "Working Capital Loan Transactions") +@Tag(name = "Working Capital Loan Transactions", description = "Working Capital Loan Transactions (e.g. disbursements).") @RequiredArgsConstructor public class WorkingCapitalLoanTransactionsApiResource { private static final String RESOURCE_NAME_FOR_PERMISSIONS = WorkingCapitalLoanConstants.WCL_RESOURCE_NAME; private final PlatformSecurityContext context; - private final WorkingCapitalLoanApplicationReadPlatformService readPlatformService; - private final WorkingCapitalLoanTransactionReadPlatformService readTransactionPlatformService; + private final WorkingCapitalLoanApplicationReadPlatformService loanReadPlatformService; + private final WorkingCapitalLoanTransactionReadPlatformService transactionReadPlatformService; + + @GET + @Path("{loanId}/transactions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionsById", summary = "Retrieve transactions", description = "Retrieves transactions of a Working Capital Loan.\n\nExample: working-capital-loans/1/transactions") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionsResponse.class))) }) + public Page retrieveTransactionsByLoanId( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @Parameter(hidden = true) @Pagination final Pageable pageable) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransactions(loanId, pageable); + } + + @GET + @Path("external-id/{loanExternalId}/transactions") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionsByExternalId", summary = "Retrieve transactions by loan external id", description = "Retrieves transactions of a Working Capital Loan by loan external id.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionsResponse.class))) }) + public Page retrieveTransactionsByExternalLoanId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @Parameter(hidden = true) @Pagination final Pageable pageable) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransactions(ExternalIdFactory.produce(loanExternalId), pageable); + } + + @GET + @Path("{loanId}/transactions/{transactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionById", summary = "Retrieve a transaction", description = "Retrieves a single Working Capital Loan transaction.\n\nExample: working-capital-loans/1/transactions/1") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionIdResponse.class))) }) + public WorkingCapitalLoanTransactionData retrieveTransactionByLoanIdAndTransactionId( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @PathParam("transactionId") @Parameter(description = "transactionId", required = true) final Long transactionId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransaction(loanId, transactionId); + } + + @GET + @Path("{loanId}/transactions/external-id/{externalTransactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionByExternalTransactionId", summary = "Retrieve a transaction by external id", description = "Retrieves a single Working Capital Loan transaction by loan id and transaction external id.\n\nExample: working-capital-loans/1/transactions/external-id/txn-ext-001") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionIdResponse.class))) }) + public WorkingCapitalLoanTransactionData retrieveTransactionByLoanIdAndTransactionExternalId( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @PathParam("externalTransactionId") @Parameter(description = "externalTransactionId", required = true) final String externalTransactionId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransaction(loanId, ExternalIdFactory.produce(externalTransactionId)); + } + + @GET + @Path("external-id/{loanExternalId}/transactions/{transactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionByExternalLoanIdAndTransactionId", summary = "Retrieve a transaction by loan external id and transaction id", description = "Retrieves a single Working Capital Loan transaction by loan external id and transaction id.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionIdResponse.class))) }) + public WorkingCapitalLoanTransactionData retrieveTransactionByExternalLoanIdAndTransactionId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @PathParam("transactionId") @Parameter(description = "transactionId", required = true) final Long transactionId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransaction(ExternalIdFactory.produce(loanExternalId), transactionId); + } + + @GET + @Path("external-id/{loanExternalId}/transactions/external-id/{externalTransactionId}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "retrieveWorkingCapitalLoanTransactionByExternalLoanIdAndExternalTransactionId", summary = "Retrieve a transaction by loan and transaction external ids", description = "Retrieves a single Working Capital Loan transaction by loan external id and transaction external id.\n\nExample: working-capital-loans/external-id/loan-ext-001/transactions/external-id/txn-ext-001") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.GetWorkingCapitalLoanTransactionIdResponse.class))) }) + public WorkingCapitalLoanTransactionData retrieveTransactionByExternalLoanIdAndTransactionExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @PathParam("externalTransactionId") @Parameter(description = "externalTransactionId", required = true) final String externalTransactionId) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.transactionReadPlatformService.retrieveTransaction(ExternalIdFactory.produce(loanExternalId), + ExternalIdFactory.produce(externalTransactionId)); + } @GET @Path("{loanId}/template") @@ -70,12 +163,12 @@ public WorkingCapitalLoanCommandTemplateData retrieveWorkingCapitalLoanTemplate( private WorkingCapitalLoanCommandTemplateData handleLoanTransactionTemplate(final Long loanId, final String loanExternalIdStr, final String templateType) { final Long resolvedLoanId = loanId != null ? loanId - : readPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalIdStr)); + : loanReadPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalIdStr)); if (resolvedLoanId == null) { throw new WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalIdStr)); } - final WorkingCapitalLoanCommandTemplateData loanTransactionTemplateData = readTransactionPlatformService + final WorkingCapitalLoanCommandTemplateData loanTransactionTemplateData = transactionReadPlatformService .retrieveLoanTransactionTemplate(resolvedLoanId, templateType); if (loanTransactionTemplateData == null) { throw new UnrecognizedQueryParamException("command", templateType); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java new file mode 100644 index 00000000000..1ec36f07450 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java @@ -0,0 +1,115 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * Swagger documentation classes for Working Capital Loan Transactions API (GET list / GET one). + */ +public final class WorkingCapitalLoanTransactionsApiResourceSwagger { + + private WorkingCapitalLoanTransactionsApiResourceSwagger() {} + + @Schema(description = "GetWorkingCapitalLoanTransactionsResponse (Spring Data Page: content, totalElements, totalPages, number, size, first, last)") + public static final class GetWorkingCapitalLoanTransactionsResponse { + + private GetWorkingCapitalLoanTransactionsResponse() {} + + public List content; + @Schema(example = "5") + public Long totalElements; + @Schema(example = "1") + public Integer totalPages; + @Schema(example = "0") + public Integer number; + @Schema(example = "20") + public Integer size; + public Boolean first; + public Boolean last; + } + + @Schema(description = "Working Capital Loan transaction (e.g. disbursement) in GET transaction response.") + public static final class GetWorkingCapitalLoanTransactionIdResponse { + + private GetWorkingCapitalLoanTransactionIdResponse() {} + + @Schema(example = "1") + public Long id; + @Schema(description = "Transaction type") + public LoanTransactionEnumData type; + @Schema(example = "[2024, 2, 1]") + public LocalDate transactionDate; + @Schema(example = "[2024, 2, 1]") + public LocalDate submittedOnDate; + @Schema(example = "10000.00") + public BigDecimal transactionAmount; + @Schema(description = "Payment detail") + public WorkingCapitalLoanTransactionPaymentDetailData paymentDetailData; + @Schema(example = "txn-ext-001") + public String externalId; + @Schema(example = "false") + public Boolean reversed; + @Schema(example = "reversal-ext-001") + public String reversalExternalId; + @Schema(example = "[2024, 2, 5]") + public LocalDate reversedOnDate; + @Schema(example = "10000.00", description = "Principal portion from allocation") + public BigDecimal principalPortion; + @Schema(example = "0.00", description = "Fee charges portion from allocation") + public BigDecimal feeChargesPortion; + @Schema(example = "0.00", description = "Penalty charges portion from allocation") + public BigDecimal penaltyChargesPortion; + } + + @Schema(description = "Loan transaction type enum data (same as basic loan)") + public static final class LoanTransactionEnumData { + + private LoanTransactionEnumData() {} + + @Schema(example = "1") + public Long id; + @Schema(example = "loanTransactionType.disbursement") + public String code; + @Schema(example = "Disbursement") + public String value; + } + + @Schema(description = "Payment detail data") + public static final class WorkingCapitalLoanTransactionPaymentDetailData { + + private WorkingCapitalLoanTransactionPaymentDetailData() {} + + @Schema(example = "62") + public Long id; + @Schema(example = "acc123") + public String accountNumber; + @Schema(example = "che123") + public String checkNumber; + @Schema(example = "rou123") + public String routingCode; + @Schema(example = "rec123") + public String receiptNumber; + @Schema(example = "ban123") + public String bankNumber; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java index eb754cdf51d..b9f01e3c432 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java @@ -73,4 +73,5 @@ public class WorkingCapitalLoanData implements Serializable { private LoanApplicationTimelineData timeline; private List disbursementDetails; private WorkingCapitalLoanBalanceData balance; + private List transactions; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java new file mode 100644 index 00000000000..e52108ba42d --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkingCapitalLoanTransactionData implements Serializable { + + private Long id; + private LoanTransactionEnumData type; + private LocalDate transactionDate; + private LocalDate submittedOnDate; + private BigDecimal transactionAmount; + private ExternalId externalId; + private Boolean reversed; + private ExternalId reversalExternalId; + private LocalDate reversedOnDate; + + private WorkingCapitalLoanTransactionPaymentDetailData paymentDetailData; + // Portions from allocation (principal, fee, penalty). + private BigDecimal principalPortion; + private BigDecimal feeChargesPortion; + private BigDecimal penaltyChargesPortion; +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java new file mode 100644 index 00000000000..cc9c31e70d3 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java @@ -0,0 +1,41 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.data; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkingCapitalLoanTransactionPaymentDetailData implements Serializable { + + private Long id; + private String accountNumber; + private String checkNumber; + private String routingCode; + private String receiptNumber; + private String bankNumber; +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java index 13420fc0b91..6330a88b2a2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java @@ -28,6 +28,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; +import jakarta.persistence.OrderBy; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import jakarta.persistence.Version; @@ -162,6 +163,10 @@ public class WorkingCapitalLoan extends AbstractAuditableWithUTCDateTimeCustom disbursementDetails = new ArrayList<>(); + @OrderBy(value = "dateOf, createdDate, id") + @OneToMany(cascade = CascadeType.ALL, mappedBy = "wcLoan", orphanRemoval = true, fetch = FetchType.LAZY) + private List transactions = new ArrayList<>(); + @Setter @Embedded private WorkingCapitalLoanProductRelatedDetails loanProductRelatedDetails; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanEvent.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanEvent.java index 58c398ab158..9d65e876a36 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanEvent.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanEvent.java @@ -22,5 +22,7 @@ public enum WorkingCapitalLoanEvent { LOAN_APPROVED, // LOAN_APPROVAL_UNDO, // - LOAN_REJECTED // + LOAN_REJECTED, // + LOAN_DISBURSED, // + LOAN_DISBURSAL_UNDO // } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java index 0d7bae6488b..82d5d6d2ada 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanLifecycleStateMachine.java @@ -35,6 +35,10 @@ public void transition(final WorkingCapitalLoanEvent event, final WorkingCapital } } + public boolean canTransition(final WorkingCapitalLoanEvent event, final WorkingCapitalLoan loan) { + return getNextStatus(event, loan) != null; + } + private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event, final WorkingCapitalLoan loan) { LoanStatus from = loan.getLoanStatus(); if (from == null) { @@ -45,6 +49,8 @@ private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event, final Work case LOAN_APPROVED -> from.isSubmittedAndPendingApproval() ? LoanStatus.APPROVED : null; case LOAN_APPROVAL_UNDO -> from.isApproved() ? LoanStatus.SUBMITTED_AND_PENDING_APPROVAL : null; case LOAN_REJECTED -> from.isSubmittedAndPendingApproval() ? LoanStatus.REJECTED : null; + case LOAN_DISBURSED -> from.isApproved() ? LoanStatus.ACTIVE : null; + case LOAN_DISBURSAL_UNDO -> from.isActive() ? LoanStatus.APPROVED : null; }; } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java new file mode 100644 index 00000000000..73d0c3231b1 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java @@ -0,0 +1,117 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; +import org.apache.fineract.infrastructure.codes.domain.CodeValue; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionTypeConverter; + +@Entity +@Table(name = "m_wc_loan_transaction", uniqueConstraints = { + @UniqueConstraint(columnNames = { "external_id" }, name = "wc_loan_transaction_external_id_UNIQUE") }) +@Getter +public class WorkingCapitalLoanTransaction extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "wc_loan_id", nullable = false) + private WorkingCapitalLoan wcLoan; + + @Column(name = "transaction_type_id", nullable = false) + @Convert(converter = LoanTransactionTypeConverter.class) + private LoanTransactionType transactionType; + + @Column(name = "transaction_date", nullable = false) + private LocalDate dateOf; + + @Column(name = "submitted_on_date", nullable = false) + private LocalDate submittedOnDate; + + @Column(name = "transaction_amount", scale = 6, precision = 19, nullable = false) + private BigDecimal transactionAmount; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "payment_detail_id") + private WorkingCapitalLoanTransactionPaymentDetail paymentDetail; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "classification_cv_id") + private CodeValue classification; + + @Column(name = "external_id", length = 100, unique = true) + @Setter + private ExternalId externalId; + + @Column(name = "is_reversed", nullable = false) + @Setter + private boolean reversed; + + @Column(name = "reversal_external_id", length = 100, unique = true) + @Setter + private ExternalId reversalExternalId; + + @Column(name = "reversed_on_date") + @Setter + private LocalDate reversedOnDate; + + @Version + @Column(name = "version") + private Integer version; + + @OneToOne(mappedBy = "wcLoanTransaction", cascade = CascadeType.ALL, orphanRemoval = true) + private WorkingCapitalLoanTransactionAllocation allocation; + + protected WorkingCapitalLoanTransaction() {} + + public LoanTransactionType getTypeOf() { + return transactionType; + } + + public static WorkingCapitalLoanTransaction disbursement(final WorkingCapitalLoan loan, final BigDecimal amount, + final WorkingCapitalLoanTransactionPaymentDetail paymentDetail, final LocalDate disbursementDate, final ExternalId externalId) { + final WorkingCapitalLoanTransaction txn = new WorkingCapitalLoanTransaction(); + txn.wcLoan = loan; + txn.transactionType = LoanTransactionType.DISBURSEMENT; + txn.dateOf = disbursementDate; + txn.submittedOnDate = disbursementDate; + txn.transactionAmount = amount; + txn.paymentDetail = paymentDetail; + txn.externalId = externalId != null ? externalId : ExternalId.empty(); + txn.reversed = false; + txn.reversalExternalId = null; + txn.reversedOnDate = null; + return txn; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java new file mode 100644 index 00000000000..de117923a64 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +@Entity +@Table(name = "m_wc_loan_transaction_allocation", uniqueConstraints = { + @UniqueConstraint(columnNames = { "wc_loan_transaction_id" }, name = "uq_m_wc_loan_transaction_allocation_transaction_id") }) +@Getter +public class WorkingCapitalLoanTransactionAllocation extends AbstractAuditableWithUTCDateTimeCustom { + + @OneToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "wc_loan_transaction_id", nullable = false, unique = true) + private WorkingCapitalLoanTransaction wcLoanTransaction; + + @Column(name = "principal_portion_derived", scale = 6, precision = 19) + @Setter + private BigDecimal principalPortion; + + @Column(name = "fee_charges_portion_derived", scale = 6, precision = 19) + @Setter + private BigDecimal feeChargesPortion; + + @Column(name = "penalty_charges_portion_derived", scale = 6, precision = 19) + @Setter + private BigDecimal penaltyChargesPortion; + + @Version + @Column(name = "version") + private Integer version; + + protected WorkingCapitalLoanTransactionAllocation() {} + + public static WorkingCapitalLoanTransactionAllocation forDisbursement(final WorkingCapitalLoanTransaction transaction, + final BigDecimal principalAmount) { + final WorkingCapitalLoanTransactionAllocation allocation = new WorkingCapitalLoanTransactionAllocation(); + allocation.wcLoanTransaction = transaction; + allocation.principalPortion = principalAmount != null ? principalAmount : BigDecimal.ZERO; + allocation.feeChargesPortion = BigDecimal.ZERO; + allocation.penaltyChargesPortion = BigDecimal.ZERO; + return allocation; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java new file mode 100644 index 00000000000..99a66ab5d2e --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java @@ -0,0 +1,59 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +@Entity +@Table(name = "m_wc_loan_transaction_payment_detail") +@Getter +public class WorkingCapitalLoanTransactionPaymentDetail extends AbstractPersistableCustom { + + @Column(name = "account_number", length = 50) + private String accountNumber; + + @Column(name = "check_number", length = 50) + private String checkNumber; + + @Column(name = "routing_code", length = 50) + private String routingCode; + + @Column(name = "receipt_number", length = 50) + private String receiptNumber; + + @Column(name = "bank_number", length = 50) + private String bankNumber; + + protected WorkingCapitalLoanTransactionPaymentDetail() {} + + public static WorkingCapitalLoanTransactionPaymentDetail of(final String accountNumber, final String checkNumber, + final String routingCode, final String receiptNumber, final String bankNumber) { + final WorkingCapitalLoanTransactionPaymentDetail d = new WorkingCapitalLoanTransactionPaymentDetail(); + d.accountNumber = accountNumber; + d.checkNumber = checkNumber; + d.routingCode = routingCode; + d.receiptNumber = receiptNumber; + d.bankNumber = bankNumber; + return d; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanTransactionNotFoundException.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanTransactionNotFoundException.java new file mode 100644 index 00000000000..070715bd88a --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/exception/WorkingCapitalLoanTransactionNotFoundException.java @@ -0,0 +1,39 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.exception; + +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +/** + * Thrown when a Working Capital Loan transaction is not found. + */ +public class WorkingCapitalLoanTransactionNotFoundException extends AbstractPlatformResourceNotFoundException { + + public WorkingCapitalLoanTransactionNotFoundException(final Long transactionId, final Long loanId) { + super("error.msg.wc.loan.transaction.not.found", "Working Capital Loan transaction with identifier " + transactionId + + " does not exist for loan with identifier " + loanId + ".", transactionId, loanId); + } + + public WorkingCapitalLoanTransactionNotFoundException(final ExternalId transactionExternalId) { + super("error.msg.wc.loan.transaction.not.found", + "Working Capital Loan transaction with external identifier " + transactionExternalId.getValue() + " does not exist", + transactionExternalId.getValue()); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DisburseWorkingCapitalLoanCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DisburseWorkingCapitalLoanCommandHandler.java new file mode 100644 index 00000000000..015794b9489 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/DisburseWorkingCapitalLoanCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@CommandType(entity = "WORKINGCAPITALLOAN", action = "DISBURSE") +public class DisburseWorkingCapitalLoanCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.disburseLoan(command.entityId(), command); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoDisburseWorkingCapitalLoanCommandHandler.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoDisburseWorkingCapitalLoanCommandHandler.java new file mode 100644 index 00000000000..9ac28d5d8d3 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/handler/UndoDisburseWorkingCapitalLoanCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@CommandType(entity = "WORKINGCAPITALLOAN", action = "DISBURSALUNDO") +public class UndoDisburseWorkingCapitalLoanCommandHandler implements NewCommandSourceHandler { + + private final WorkingCapitalLoanWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.undoDisbursal(command.entityId(), command); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java index c30aa67cc80..97b260cc48b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanMapper.java @@ -46,7 +46,8 @@ import org.mapstruct.factory.Mappers; @Mapper(config = MapstructMapperConfig.class, uses = { DelinquencyBucketMapper.class, WorkingCapitalLoanProductMapper.class, - WorkingCapitalLoanBalanceMapper.class, WorkingCapitalLoanDisbursementDetailMapper.class }) + WorkingCapitalLoanBalanceMapper.class, WorkingCapitalLoanDisbursementDetailMapper.class, + WorkingCapitalLoanTransactionMapper.class }) public interface WorkingCapitalLoanMapper { @Mapping(target = "accountNo", source = "accountNumber") @@ -66,6 +67,7 @@ public interface WorkingCapitalLoanMapper { @Mapping(target = "paymentAllocation", source = "paymentAllocationRules", qualifiedByName = "paymentAllocationRulesToData") @Mapping(target = "timeline", source = "loan", qualifiedByName = "timelineData") @Mapping(target = "disbursementDetails", source = "disbursementDetails") + @Mapping(target = "transactions", source = "transactions") WorkingCapitalLoanData toData(WorkingCapitalLoan loan); List toDataList(List loans); @@ -120,6 +122,8 @@ default LoanApplicationTimelineData timelineData(final WorkingCapitalLoan loan) : loan.getDisbursementDetails().getFirst().getExpectedDisbursementDate(); timelineData.setExpectedDisbursementDate(expectedDisbursementDate); timelineData.setSubmittedOnDate(loan.getSubmittedOnDate()); + timelineData.setExpectedMaturityDate(loan.getExpectedMaturityDate()); + timelineData.setActualMaturityDate(loan.getMaturedOnDate()); if (loan.getApprovedBy() != null) { timelineData.setApprovedByUsername(loan.getApprovedBy().getUsername()); timelineData.setApprovedByFirstname(loan.getApprovedBy().getFirstname()); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java new file mode 100644 index 00000000000..d5017da9bd9 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.mapper; + +import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig; +import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionPaymentDetailData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +@Mapper(config = MapstructMapperConfig.class) +public interface WorkingCapitalLoanTransactionMapper { + + @Mapping(target = "type", source = "transactionType", qualifiedByName = "loanTransactionTypeToEnumData") + @Mapping(target = "paymentDetailData", source = "paymentDetail", qualifiedByName = "paymentDetailToData") + @Mapping(target = "transactionDate", source = "dateOf") + @Mapping(target = "principalPortion", source = "allocation.principalPortion") + @Mapping(target = "feeChargesPortion", source = "allocation.feeChargesPortion") + @Mapping(target = "penaltyChargesPortion", source = "allocation.penaltyChargesPortion") + WorkingCapitalLoanTransactionData toData(WorkingCapitalLoanTransaction transaction); + + @Named("loanTransactionTypeToEnumData") + default LoanTransactionEnumData loanTransactionTypeToEnumData(final LoanTransactionType type) { + return type == null ? null : LoanEnumerations.transactionType(type); + } + + @Named("paymentDetailToData") + default WorkingCapitalLoanTransactionPaymentDetailData paymentDetailToData( + final WorkingCapitalLoanTransactionPaymentDetail paymentDetail) { + if (paymentDetail == null) { + return null; + } + return WorkingCapitalLoanTransactionPaymentDetailData.builder().id(paymentDetail.getId()) + .accountNumber(paymentDetail.getAccountNumber()).checkNumber(paymentDetail.getCheckNumber()) + .routingCode(paymentDetail.getRoutingCode()).receiptNumber(paymentDetail.getReceiptNumber()) + .bankNumber(paymentDetail.getBankNumber()).build(); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBalanceRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBalanceRepository.java new file mode 100644 index 00000000000..cf5e11bd301 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanBalanceRepository.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.repository; + +import java.util.Optional; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanBalanceRepository extends JpaRepository { + + Optional findByWcLoan_Id(Long wcLoanId); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanRepository.java index 9b68d08bfdd..d8056bb6d0e 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanRepository.java @@ -48,6 +48,8 @@ public interface WorkingCapitalLoanRepository extends JpaRepository findByExternalIdWithDetails(@Param("externalId") ExternalId externalId); @@ -70,6 +74,8 @@ public interface WorkingCapitalLoanRepository extends JpaRepository findByIdInWithFullDetails(@Param("ids") List ids); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java new file mode 100644 index 00000000000..ee2d1370a16 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionAllocationRepository.java @@ -0,0 +1,24 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.repository; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanTransactionAllocationRepository extends JpaRepository {} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java new file mode 100644 index 00000000000..0e37197bdb3 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java @@ -0,0 +1,25 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.repository; + +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanTransactionPaymentDetailRepository + extends JpaRepository {} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java new file mode 100644 index 00000000000..7429dd17246 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.repository; + +import java.util.List; +import java.util.Optional; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WorkingCapitalLoanTransactionRepository extends JpaRepository { + + List findByWcLoan_IdOrderByDateOfAscIdAsc(Long wcLoanId); + + Page findByWcLoan_IdOrderByDateOfAscIdAsc(Long wcLoanId, Pageable pageable); + + Optional findByIdAndWcLoan_Id(Long id, Long wcLoanId); + + Optional findByWcLoan_IdAndExternalId(Long wcLoanId, ExternalId externalId); + + boolean existsByExternalId(ExternalId externalId); +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index ea926f23e50..f2a88a1cd52 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.workingcapitalloan.serialization; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.math.BigDecimal; @@ -33,12 +34,17 @@ import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; +import org.apache.fineract.portfolio.loanaccount.domain.ExpectedDisbursementDateValidator; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.springframework.stereotype.Component; @Component @@ -46,6 +52,8 @@ public class WorkingCapitalLoanDataValidator { private final FromJsonHelper fromApiJsonHelper; + private final ExpectedDisbursementDateValidator expectedDisbursementDateValidator; + private final WorkingCapitalLoanTransactionRepository transactionRepository; // Per requirement: only principal, discount, approved date, expected disbursement date, and notes private static final Set APPROVAL_SUPPORTED_PARAMETERS = new HashSet<>( @@ -59,6 +67,23 @@ public class WorkingCapitalLoanDataValidator { private static final Set UNDO_APPROVAL_SUPPORTED_PARAMETERS = new HashSet<>( Arrays.asList("locale", "dateFormat", WorkingCapitalLoanConstants.noteParamName)); + private static final Set DISBURSAL_SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("locale", "dateFormat", + WorkingCapitalLoanConstants.actualDisbursementDateParamName, WorkingCapitalLoanConstants.transactionAmountParamName, + WorkingCapitalLoanConstants.discountAmountParamName, WorkingCapitalLoanConstants.noteParamName, + WorkingCapitalLoanConstants.paymentDetailsParamName, WorkingCapitalLoanConstants.externalIdParameterName)); + + private static final Set PAYMENT_DETAILS_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList(WorkingCapitalLoanConstants.paymentTypeIdParamName, WorkingCapitalLoanConstants.accountNumberParamName, + WorkingCapitalLoanConstants.checkNumberParamName, WorkingCapitalLoanConstants.routingCodeParamName, + WorkingCapitalLoanConstants.receiptNumberParamName, WorkingCapitalLoanConstants.bankNumberParamName)); + + private static final Set UNDO_DISBURSAL_SUPPORTED_PARAMETERS = new HashSet<>( + Arrays.asList("locale", "dateFormat", WorkingCapitalLoanConstants.noteParamName)); + + private static final int NOTE_MAX_LENGTH = 1000; + private static final int EXTERNAL_ID_MAX_LENGTH = 100; + private static final int PAYMENT_DETAIL_STRING_MAX_LENGTH = 50; + public void validateApproval(final String json, final WorkingCapitalLoan loan) { if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); @@ -175,6 +200,157 @@ public void validateUndoApproval(final String json) { this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UNDO_APPROVAL_SUPPORTED_PARAMETERS); } + public void validateDisbursement(final String json, final WorkingCapitalLoan loan) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DISBURSAL_SUPPORTED_PARAMETERS); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + if (element != null && element.isJsonObject()) { + final JsonObject root = element.getAsJsonObject(); + if (root.has(WorkingCapitalLoanConstants.paymentDetailsParamName) + && root.get(WorkingCapitalLoanConstants.paymentDetailsParamName).isJsonObject()) { + final String paymentDetailsJson = root.getAsJsonObject(WorkingCapitalLoanConstants.paymentDetailsParamName).toString(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, paymentDetailsJson, PAYMENT_DETAILS_SUPPORTED_PARAMETERS); + } + } + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + + final LocalDate actualDisbursementDate = this.fromApiJsonHelper + .extractLocalDateNamed(WorkingCapitalLoanConstants.actualDisbursementDateParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.actualDisbursementDateParamName).value(actualDisbursementDate) + .notNull(); + + if (actualDisbursementDate != null) { + if (DateUtils.isDateInTheFuture(actualDisbursementDate)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.actualDisbursementDateParamName) + .failWithCode("cannot.be.a.future.date"); + } + + if (loan.getSubmittedOnDate() != null && DateUtils.isBefore(actualDisbursementDate, loan.getSubmittedOnDate())) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.actualDisbursementDateParamName) + .failWithCode("cannot.be.before.submitted.date"); + } + + if (loan.getApprovedOnDate() != null && DateUtils.isBefore(actualDisbursementDate, loan.getApprovedOnDate())) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.actualDisbursementDateParamName) + .failWithCode("cannot.be.before.approval.date"); + } + } + + // Align with Loan: disbursement not allowed when client is not active + if (loan.getClient() != null && loan.getClient().isNotActive()) { + throw new ClientNotActiveException(loan.getClient().getId()); + } + + // Align with Loan and WCL application: actual disbursement date not on non-working day or holiday when + // disallowed + if (actualDisbursementDate != null && loan.getOfficeId() != null) { + this.expectedDisbursementDateValidator.validate(actualDisbursementDate, loan.getOfficeId()); + } + + final BigDecimal transactionAmount = this.fromApiJsonHelper + .extractBigDecimalNamed(WorkingCapitalLoanConstants.transactionAmountParamName, element, new HashSet<>()); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionAmountParamName).value(transactionAmount).notNull() + .positiveAmount(); + if (transactionAmount != null && loan.getApprovedPrincipal() != null + && transactionAmount.compareTo(loan.getApprovedPrincipal()) > 0) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.transactionAmountParamName) + .failWithCode("amount.cannot.exceed.approved.principal"); + } + + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.discountAmountParamName, element)) { + final BigDecimal discountAmount = this.fromApiJsonHelper + .extractBigDecimalNamed(WorkingCapitalLoanConstants.discountAmountParamName, element, new HashSet<>()); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.discountAmountParamName).value(discountAmount).ignoreIfNull() + .zeroOrPositiveAmount(); + + final BigDecimal currentDiscount = loan.getLoanProductRelatedDetails() != null + ? loan.getLoanProductRelatedDetails().getDiscount() + : null; + if (discountAmount != null && currentDiscount != null && discountAmount.compareTo(currentDiscount) > 0) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.discountAmountParamName) + .failWithCode("amount.cannot.exceed.created.discount"); + } + } + + final String note = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.noteParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.noteParamName).value(note).ignoreIfNull() + .notExceedingLengthOf(NOTE_MAX_LENGTH); + + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.externalIdParameterName, element)) { + final String externalIdStr = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.externalIdParameterName, + element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.externalIdParameterName).value(externalIdStr).ignoreIfNull() + .notExceedingLengthOf(EXTERNAL_ID_MAX_LENGTH); + if (externalIdStr != null && !externalIdStr.isBlank()) { + final ExternalId externalId = ExternalIdFactory.produce(externalIdStr); + if (!externalId.isEmpty() && this.transactionRepository.existsByExternalId(externalId)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.externalIdParameterName).failWithCode("already.exists"); + } + } + } + + validateDisbursementPaymentDetails(baseDataValidator, element); + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + /** + * Validates payment details inside paymentDetails object: paymentTypeId integerGreaterThanZero when present; + * accountNumber, checkNumber, routingCode, receiptNumber, bankNumber notExceedingLengthOf(50) when present. + */ + private void validateDisbursementPaymentDetails(final DataValidatorBuilder baseDataValidator, final JsonElement element) { + final JsonElement paymentDetailsElement = resolvePaymentDetailsElement(element); + final Integer paymentTypeId = this.fromApiJsonHelper + .extractIntegerSansLocaleNamed(WorkingCapitalLoanConstants.paymentTypeIdParamName, paymentDetailsElement); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull() + .integerGreaterThanZero(); + + for (final String paramName : Arrays.asList(WorkingCapitalLoanConstants.accountNumberParamName, + WorkingCapitalLoanConstants.checkNumberParamName, WorkingCapitalLoanConstants.routingCodeParamName, + WorkingCapitalLoanConstants.receiptNumberParamName, WorkingCapitalLoanConstants.bankNumberParamName)) { + final String value = this.fromApiJsonHelper.extractStringNamed(paramName, paymentDetailsElement); + baseDataValidator.reset().parameter(paramName).value(value).ignoreIfNull() + .notExceedingLengthOf(PAYMENT_DETAIL_STRING_MAX_LENGTH); + } + } + + private JsonElement resolvePaymentDetailsElement(final JsonElement element) { + if (element != null && element.isJsonObject()) { + final JsonObject root = element.getAsJsonObject(); + if (root.has(WorkingCapitalLoanConstants.paymentDetailsParamName) + && root.get(WorkingCapitalLoanConstants.paymentDetailsParamName).isJsonObject()) { + return root.getAsJsonObject(WorkingCapitalLoanConstants.paymentDetailsParamName); + } + } + return element; + } + + public void validateUndoDisbursal(final String json) { + if (StringUtils.isBlank(json)) { + return; + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, UNDO_DISBURSAL_SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(WorkingCapitalLoanConstants.RESOURCE_NAME); + final JsonElement element = this.fromApiJsonHelper.parse(json); + final String note = this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanConstants.noteParamName, element); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.noteParamName).value(note).ignoreIfNull() + .notExceedingLengthOf(NOTE_MAX_LENGTH); + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException(dataValidationErrors); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java index 2f6958cbad7..d9fe7437468 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java @@ -18,9 +18,16 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; +import java.math.BigDecimal; +import java.time.LocalDate; import org.apache.fineract.portfolio.workingcapitalloan.data.ProjectedAmortizationScheduleGenerateRequest; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; public interface WorkingCapitalLoanAmortizationScheduleWriteService { void generateAndSaveAmortizationSchedule(Long loanId, ProjectedAmortizationScheduleGenerateRequest request); + + void generateAndSaveAmortizationScheduleOnDisbursement(WorkingCapitalLoan loan, BigDecimal disbursedAmount, LocalDate disbursementDate); + + void regenerateAmortizationScheduleOnUndoDisbursal(WorkingCapitalLoan loan); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java index f8729c0b610..2f3878d7872 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java @@ -18,13 +18,17 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; +import java.math.BigDecimal; import java.math.MathContext; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.Validate; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel; import org.apache.fineract.portfolio.workingcapitalloan.data.ProjectedAmortizationScheduleGenerateRequest; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.springframework.stereotype.Service; @@ -39,7 +43,6 @@ @Transactional public class WorkingCapitalLoanAmortizationScheduleWriteServiceImpl implements WorkingCapitalLoanAmortizationScheduleWriteService { - // TODO: currency should come from loan product once WCL lifecycle is implemented private static final MonetaryCurrency DEFAULT_CURRENCY = new MonetaryCurrency("USD", 2, null); private final WorkingCapitalLoanRepository loanRepository; @@ -62,4 +65,77 @@ public void generateAndSaveAmortizationSchedule(final Long loanId, final Project scheduleRepositoryWrapper.writeModel(loan, model); } + + @Override + public void generateAndSaveAmortizationScheduleOnDisbursement(final WorkingCapitalLoan loan, final BigDecimal disbursedAmount, + final LocalDate disbursementDate) { + Validate.notNull(loan, "loan must not be null"); + Validate.notNull(disbursedAmount, "disbursedAmount must not be null"); + Validate.notNull(disbursementDate, "disbursementDate must not be null"); + + final MathContext mc = MoneyHelper.getMathContext(); + final BigDecimal discount = loan.getLoanProductRelatedDetails() != null && loan.getLoanProductRelatedDetails().getDiscount() != null + ? loan.getLoanProductRelatedDetails().getDiscount() + : BigDecimal.ZERO; + final BigDecimal totalPayment = loan.getBalance() != null && loan.getBalance().getTotalPayment() != null + ? loan.getBalance().getTotalPayment() + : BigDecimal.ZERO; + final BigDecimal periodPaymentRate = loan.getLoanProductRelatedDetails() != null + ? loan.getLoanProductRelatedDetails().getPeriodPaymentRate() + : null; + final Integer npvDayCount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getNpvDayCount() + : null; + + Validate.isTrue(totalPayment.signum() > 0, "totalPayment must be positive"); + Validate.notNull(periodPaymentRate, "periodPaymentRate must not be null"); + Validate.notNull(npvDayCount, "npvDayCount must not be null"); + + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(discount, disbursedAmount, + totalPayment, periodPaymentRate, npvDayCount, disbursementDate, mc, resolveCurrency(loan)); + scheduleRepositoryWrapper.writeModel(loan, model); + } + + @Override + public void regenerateAmortizationScheduleOnUndoDisbursal(final WorkingCapitalLoan loan) { + Validate.notNull(loan, "loan must not be null"); + + final MathContext mc = MoneyHelper.getMathContext(); + final BigDecimal discount = loan.getLoanProductRelatedDetails() != null && loan.getLoanProductRelatedDetails().getDiscount() != null + ? loan.getLoanProductRelatedDetails().getDiscount() + : BigDecimal.ZERO; + final BigDecimal totalPayment = loan.getBalance() != null && loan.getBalance().getTotalPayment() != null + ? loan.getBalance().getTotalPayment() + : BigDecimal.ZERO; + final BigDecimal periodPaymentRate = loan.getLoanProductRelatedDetails() != null + ? loan.getLoanProductRelatedDetails().getPeriodPaymentRate() + : null; + final Integer npvDayCount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getNpvDayCount() + : null; + + final WorkingCapitalLoanDisbursementDetails detail = loan.getDisbursementDetails() != null + && !loan.getDisbursementDetails().isEmpty() ? loan.getDisbursementDetails().getFirst() : null; + final LocalDate expectedDisbursementDate = detail != null ? detail.getExpectedDisbursementDate() : null; + final BigDecimal expectedAmount = detail != null && detail.getExpectedAmount() != null ? detail.getExpectedAmount() + : loan.getApprovedPrincipal(); + + Validate.isTrue(totalPayment.signum() > 0, "totalPayment must be positive"); + Validate.notNull(periodPaymentRate, "periodPaymentRate must not be null"); + Validate.notNull(npvDayCount, "npvDayCount must not be null"); + Validate.notNull(expectedDisbursementDate, "expectedDisbursementDate must not be null"); + Validate.notNull(expectedAmount, "expectedAmount must not be null"); + + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(discount, expectedAmount, totalPayment, + periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, resolveCurrency(loan)); + scheduleRepositoryWrapper.writeModel(loan, model); + } + + private MonetaryCurrency resolveCurrency(final WorkingCapitalLoan loan) { + if (loan.getLoanProductRelatedDetails() != null && loan.getLoanProductRelatedDetails().getCurrency() != null) { + return loan.getLoanProductRelatedDetails().getCurrency(); + } + if (loan.getLoanProduct() != null && loan.getLoanProduct().getCurrency() != null) { + return loan.getLoanProduct().getCurrency(); + } + return DEFAULT_CURRENCY; + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java index 5431ff2c55d..18a149ce844 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java @@ -18,10 +18,43 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; +import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCommandTemplateData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface WorkingCapitalLoanTransactionReadPlatformService { WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(Long loanId, String command); + /** + * Retrieves paginated transactions of a Working Capital Loan by loan id. + */ + Page retrieveTransactions(Long loanId, Pageable pageable); + + /** + * Retrieves paginated transactions of a Working Capital Loan by loan external id. + */ + Page retrieveTransactions(ExternalId loanExternalId, Pageable pageable); + + /** + * Retrieves a single Working Capital Loan transaction by loan id and transaction id. + */ + WorkingCapitalLoanTransactionData retrieveTransaction(Long loanId, Long transactionId); + + /** + * Retrieves a single Working Capital Loan transaction by loan external id and transaction id. + */ + WorkingCapitalLoanTransactionData retrieveTransaction(ExternalId loanExternalId, Long transactionId); + + /** + * Retrieves a single Working Capital Loan transaction by loan id and transaction external id. + */ + WorkingCapitalLoanTransactionData retrieveTransaction(Long loanId, ExternalId transactionExternalId); + + /** + * Retrieves a single Working Capital Loan transaction by loan external id and transaction external id. + */ + WorkingCapitalLoanTransactionData retrieveTransaction(ExternalId loanExternalId, ExternalId transactionExternalId); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java index 13d5bd72a81..9f1c04f2ddf 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java @@ -19,27 +19,41 @@ package org.apache.fineract.portfolio.workingcapitalloan.service; import java.time.LocalDate; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCommandTemplateData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanTransactionNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.mapper.WorkingCapitalLoanTransactionMapper; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; -import org.springframework.stereotype.Component; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -@Component +@Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class WorkingCapitalLoanTransactionReadPlatformServiceImpl implements WorkingCapitalLoanTransactionReadPlatformService { - private final WorkingCapitalLoanRepository repository; + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanRepository workingCapitalLoanRepository; private final PaymentTypeReadService paymentTypeReadPlatformService; + private final WorkingCapitalLoanTransactionMapper transactionMapper; @Override public WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(final Long loanId, final String command) { final WorkingCapitalLoan wcLoan = retrieveWorkingCapitalLoan(loanId); - final LocalDate expectedDisbursementDate = wcLoan.getDisbursementDetails().get(0).getExpectedDisbursementDate(); + final LocalDate expectedDisbursementDate = wcLoan.getDisbursementDetails().getFirst().getExpectedDisbursementDate(); if (WorkingCapitalLoanConstants.APPROVE_LOAN_COMMAND.equals(command)) { return WorkingCapitalLoanCommandTemplateData.builder().approvalAmount(wcLoan.getProposedPrincipal()) .approvalDate(expectedDisbursementDate).expectedDisbursementDate(expectedDisbursementDate) @@ -52,8 +66,69 @@ public WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(fin return null; } - private WorkingCapitalLoan retrieveWorkingCapitalLoan(final Long loanId) { - return repository.findByIdWithFullDetails(loanId).orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + @Override + public Page retrieveTransactions(final Long loanId, final Pageable pageable) { + ensureLoanExists(loanId); + final Page page = this.transactionRepository.findByWcLoan_IdOrderByDateOfAscIdAsc(loanId, pageable); + final List content = page.getContent().stream().map(this.transactionMapper::toData).toList(); + return new PageImpl<>(content, page.getPageable(), page.getTotalElements()); + } + + @Override + public Page retrieveTransactions(final ExternalId loanExternalId, final Pageable pageable) { + final Long loanId = getResolvedLoanId(loanExternalId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(loanExternalId); + } + return retrieveTransactions(loanId, pageable); + } + + @Override + public WorkingCapitalLoanTransactionData retrieveTransaction(final Long loanId, final Long transactionId) { + ensureLoanExists(loanId); + final WorkingCapitalLoanTransaction txn = this.transactionRepository.findByIdAndWcLoan_Id(transactionId, loanId) + .orElseThrow(() -> new WorkingCapitalLoanTransactionNotFoundException(transactionId, loanId)); + return this.transactionMapper.toData(txn); } + @Override + public WorkingCapitalLoanTransactionData retrieveTransaction(final ExternalId loanExternalId, final Long transactionId) { + final Long loanId = getResolvedLoanId(loanExternalId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(loanExternalId); + } + return retrieveTransaction(loanId, transactionId); + } + + @Override + public WorkingCapitalLoanTransactionData retrieveTransaction(final Long loanId, final ExternalId transactionExternalId) { + ensureLoanExists(loanId); + final WorkingCapitalLoanTransaction txn = this.transactionRepository.findByWcLoan_IdAndExternalId(loanId, transactionExternalId) + .orElseThrow(() -> new WorkingCapitalLoanTransactionNotFoundException(transactionExternalId)); + return this.transactionMapper.toData(txn); + } + + @Override + public WorkingCapitalLoanTransactionData retrieveTransaction(final ExternalId loanExternalId, final ExternalId transactionExternalId) { + final Long loanId = getResolvedLoanId(loanExternalId); + if (loanId == null) { + throw new WorkingCapitalLoanNotFoundException(loanExternalId); + } + return retrieveTransaction(loanId, transactionExternalId); + } + + private Long getResolvedLoanId(final ExternalId externalId) { + return this.workingCapitalLoanRepository.findByExternalId(externalId).map(WorkingCapitalLoan::getId).orElse(null); + } + + private void ensureLoanExists(final Long loanId) { + if (!this.workingCapitalLoanRepository.existsById(loanId)) { + throw new WorkingCapitalLoanNotFoundException(loanId); + } + } + + private WorkingCapitalLoan retrieveWorkingCapitalLoan(final Long loanId) { + return workingCapitalLoanRepository.findByIdWithFullDetails(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java index 5b22ea9a73e..3571493272a 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformService.java @@ -28,4 +28,8 @@ public interface WorkingCapitalLoanWritePlatformService { CommandProcessingResult undoApplicationApproval(Long loanId, JsonCommand command); CommandProcessingResult rejectApplication(Long loanId, JsonCommand command); + + CommandProcessingResult disburseLoan(Long loanId, JsonCommand command); + + CommandProcessingResult undoDisbursal(Long loanId, JsonCommand command); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index a93cd5cac56..4c51d0fe1be 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -18,32 +18,51 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; +import com.google.gson.JsonElement; import java.math.BigDecimal; import java.time.LocalDate; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanEvent; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanLifecycleStateMachine; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionAllocationRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionPaymentDetailRepository; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanDataValidator; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @@ -56,6 +75,12 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final WorkingCapitalLoanLifecycleStateMachine stateMachine; private final FromJsonHelper fromApiJsonHelper; private final WorkingCapitalLoanNoteRepository noteRepository; + private final ExternalIdFactory externalIdFactory; + private final WorkingCapitalLoanTransactionRepository transactionRepository; + private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; + private final WorkingCapitalLoanTransactionPaymentDetailRepository paymentDetailRepository; + private final WorkingCapitalLoanBalanceRepository balanceRepository; + private final WorkingCapitalLoanAmortizationScheduleWriteService amortizationScheduleWriteService; @Override public CommandProcessingResult approveApplication(final Long loanId, final JsonCommand command) { @@ -181,6 +206,203 @@ public CommandProcessingResult rejectApplication(final Long loanId, final JsonCo .withLoanId(loanId).with(changes).build(); } + @Transactional + @Override + public CommandProcessingResult disburseLoan(final Long loanId, final JsonCommand command) { + final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + + if (!this.stateMachine.canTransition(WorkingCapitalLoanEvent.LOAN_DISBURSED, loan)) { + throw new PlatformApiDataValidationException("validation.msg.wc.loan.transition.not.allowed", + "Disbursement is not allowed from current status " + loan.getLoanStatus(), "loanStatus"); + } + + this.validator.validateDisbursement(command.json(), loan); + + final AppUser currentUser = this.context.getAuthenticatedUserIfPresent(); + + final LocalDate actualDisbursementDate = command + .localDateValueOfParameterNamed(WorkingCapitalLoanConstants.actualDisbursementDateParamName); + final BigDecimal transactionAmount = this.fromApiJsonHelper + .extractBigDecimalNamed(WorkingCapitalLoanConstants.transactionAmountParamName, command.parsedJson(), new HashSet<>()); + + final Map changes = new LinkedHashMap<>(); + changes.put(WorkingCapitalLoanConstants.actualDisbursementDateParamName, actualDisbursementDate); + changes.put(WorkingCapitalLoanConstants.transactionAmountParamName, transactionAmount); + final WorkingCapitalLoanTransactionPaymentDetail paymentDetail = createAndPersistPaymentDetailFromCommand(command, changes); + + this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_DISBURSED, loan); + + if (!loan.getDisbursementDetails().isEmpty()) { + loan.getDisbursementDetails().getFirst().setActualDisbursementDate(actualDisbursementDate); + loan.getDisbursementDetails().getFirst().setActualAmount(transactionAmount); + loan.getDisbursementDetails().getFirst().setDisbursedBy(currentUser); + } + + if (command.parameterExists(WorkingCapitalLoanConstants.discountAmountParamName)) { + final BigDecimal discount = this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.discountAmountParamName, + command.parsedJson(), new HashSet<>()); + if (discount != null) { + loan.getLoanProductRelatedDetails().setDiscount(discount); + changes.put(WorkingCapitalLoanConstants.discountAmountParamName, discount); + } + } + + final ExternalId txnExternalId = this.externalIdFactory.createFromCommand(command, + WorkingCapitalLoanConstants.externalIdParameterName); + final WorkingCapitalLoanTransaction disbursementTransaction = WorkingCapitalLoanTransaction.disbursement(loan, transactionAmount, + paymentDetail, actualDisbursementDate, txnExternalId); + this.transactionRepository.saveAndFlush(disbursementTransaction); + + final WorkingCapitalLoanTransactionAllocation allocation = WorkingCapitalLoanTransactionAllocation + .forDisbursement(disbursementTransaction, transactionAmount); + this.allocationRepository.saveAndFlush(allocation); + + updateBalanceOnDisburse(loan, transactionAmount); + amortizationScheduleWriteService.generateAndSaveAmortizationScheduleOnDisbursement(loan, transactionAmount, actualDisbursementDate); + + this.loanRepository.saveAndFlush(loan); + + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); + if (StringUtils.isNotBlank(noteText)) { + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + changes.put("status", loan.getLoanStatus()); + createNote(noteText, loan); + + log.debug("Working capital loan {} disbursed by user {}", loanId, currentUser != null ? currentUser.getId() : "system"); + + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .withSubEntityId(disbursementTransaction.getId()) // + .withSubEntityExternalId(disbursementTransaction.getExternalId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loanId) // + .with(changes) // + .build(); + } + + @Override + public CommandProcessingResult undoDisbursal(final Long loanId, final JsonCommand command) { + final WorkingCapitalLoan loan = this.loanRepository.findById(loanId) + .orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); + + this.validator.validateUndoDisbursal(command.json()); + + if (loan.getClient() != null && loan.getClient().isNotActive()) { + throw new ClientNotActiveException(loan.getClient().getId()); + } + + this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_DISBURSAL_UNDO, loan); + + reverseDisbursementTransactionsAndResetBalance(loan); + + if (loan.getDisbursementDetails() != null) { + for (WorkingCapitalLoanDisbursementDetails detail : loan.getDisbursementDetails()) { + if (detail.getActualDisbursementDate() != null) { + detail.setActualDisbursementDate(null); + detail.setActualAmount(null); + detail.setDisbursedBy(null); + } + } + } + amortizationScheduleWriteService.regenerateAmortizationScheduleOnUndoDisbursal(loan); + + this.loanRepository.saveAndFlush(loan); + + final Map changes = new LinkedHashMap<>(); + changes.put("status", loan.getLoanStatus()); + changes.put(WorkingCapitalLoanConstants.actualDisbursementDateParamName, null); + changes.put("actualAmount", null); + final String noteText = command.stringValueOfParameterNamed(WorkingCapitalLoanConstants.noteParamName); + if (StringUtils.isNotBlank(noteText)) { + changes.put(WorkingCapitalLoanConstants.noteParamName, noteText); + } + createNote(noteText, loan); + + log.debug("Working capital loan {} disbursal undone", loanId); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId) + .withEntityExternalId(loan.getExternalId()).withLoanId(loanId).with(changes).build(); + } + + private WorkingCapitalLoanTransactionPaymentDetail createAndPersistPaymentDetailFromCommand(final JsonCommand command, + final Map changes) { + final JsonElement paymentDetailsElement = command.jsonElement(WorkingCapitalLoanConstants.paymentDetailsParamName); + if (paymentDetailsElement != null && paymentDetailsElement.isJsonObject()) { + final JsonCommand paymentDetailsCommand = JsonCommand.fromExistingCommand(command, paymentDetailsElement); + return createAndPersistWclPaymentDetail(paymentDetailsCommand, changes); + } + return createAndPersistWclPaymentDetail(command, changes); + } + + private WorkingCapitalLoanTransactionPaymentDetail createAndPersistWclPaymentDetail(final JsonCommand paymentDetailsCommand, + final Map changes) { + final String accountNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.accountNumberParamName); + final String checkNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.checkNumberParamName); + final String routingCode = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.routingCodeParamName); + final String receiptNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.receiptNumberParamName); + final String bankNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.bankNumberParamName); + + final boolean hasAny = StringUtils.isNotBlank(accountNumber) || StringUtils.isNotBlank(checkNumber) + || StringUtils.isNotBlank(routingCode) || StringUtils.isNotBlank(receiptNumber) || StringUtils.isNotBlank(bankNumber); + if (!hasAny) { + return null; + } + + if (StringUtils.isNotBlank(accountNumber)) { + changes.put(WorkingCapitalLoanConstants.accountNumberParamName, accountNumber); + } + if (StringUtils.isNotBlank(checkNumber)) { + changes.put(WorkingCapitalLoanConstants.checkNumberParamName, checkNumber); + } + if (StringUtils.isNotBlank(routingCode)) { + changes.put(WorkingCapitalLoanConstants.routingCodeParamName, routingCode); + } + if (StringUtils.isNotBlank(receiptNumber)) { + changes.put(WorkingCapitalLoanConstants.receiptNumberParamName, receiptNumber); + } + if (StringUtils.isNotBlank(bankNumber)) { + changes.put(WorkingCapitalLoanConstants.bankNumberParamName, bankNumber); + } + + final WorkingCapitalLoanTransactionPaymentDetail detail = WorkingCapitalLoanTransactionPaymentDetail.of(accountNumber, checkNumber, + routingCode, receiptNumber, bankNumber); + return this.paymentDetailRepository.saveAndFlush(detail); + } + + private void updateBalanceOnDisburse(final WorkingCapitalLoan loan, final BigDecimal disbursedAmount) { + WorkingCapitalLoanBalance balance = this.balanceRepository.findByWcLoan_Id(loan.getId()).orElse(null); + if (balance == null) { + balance = WorkingCapitalLoanBalance.createFor(loan); + } + balance.setPrincipalOutstanding(disbursedAmount); + this.balanceRepository.saveAndFlush(balance); + } + + private void reverseDisbursementTransactionsAndResetBalance(final WorkingCapitalLoan loan) { + final List transactions = this.transactionRepository + .findByWcLoan_IdOrderByDateOfAscIdAsc(loan.getId()); + for (WorkingCapitalLoanTransaction txn : transactions) { + if (txn.getTypeOf() == LoanTransactionType.DISBURSEMENT && !txn.isReversed()) { + txn.setReversed(true); + txn.setReversedOnDate(DateUtils.getBusinessLocalDate()); + txn.setReversalExternalId(ExternalId.generate()); + this.transactionRepository.save(txn); + } + } + this.transactionRepository.flush(); + + final Optional balanceOpt = this.balanceRepository.findByWcLoan_Id(loan.getId()); + balanceOpt.ifPresent(b -> { + b.setTotalPaidPrincipal(BigDecimal.ZERO); + this.balanceRepository.saveAndFlush(b); + }); + } + private void createNote(final String noteText, final WorkingCapitalLoan loan) { if (StringUtils.isNotBlank(noteText)) { final WorkingCapitalLoanNote note = WorkingCapitalLoanNote.create(loan, noteText); diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 0946a57d437..04c54906cc0 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -32,4 +32,6 @@ + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0011_wc_loan_transaction.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0011_wc_loan_transaction.xml new file mode 100644 index 00000000000..d8568d79ebd --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0011_wc_loan_transaction.xml @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0012_wc_loan_disbursement_permissions.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0012_wc_loan_disbursement_permissions.xml new file mode 100644 index 00000000000..fa2aa527d56 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0012_wc_loan_disbursement_permissions.xml @@ -0,0 +1,56 @@ + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'DISBURSE_WORKINGCAPITALLOAN'; + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'DISBURSALUNDO_WORKINGCAPITALLOAN'; + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml b/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml index feee760ecf2..418d0049e14 100644 --- a/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml +++ b/fineract-working-capital-loan/src/main/resources/jpa/static-weaving/module/fineract-working-capital-loan/persistence.xml @@ -116,6 +116,7 @@ org.apache.fineract.portfolio.loanaccount.domain.LoanRescheduleRequestToTermVariationMapping org.apache.fineract.portfolio.loanaccount.domain.LoanStatusChangeHistory org.apache.fineract.portfolio.loanaccount.domain.LoanStatusConverter + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionTypeConverter org.apache.fineract.portfolio.loanaccount.domain.LoanSubStatusConverter org.apache.fineract.portfolio.loanaccount.domain.LoanTermVariations org.apache.fineract.portfolio.loanaccount.domain.LoanTopupDetails @@ -160,6 +161,10 @@ org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPaymentAllocationRule + org.apache.fineract.portfolio.workingcapitalloan.domain.ProjectedAmortizationLoanModel + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation + org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductPaymentAllocationRule diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java new file mode 100644 index 00000000000..3b16be53c08 --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java @@ -0,0 +1,106 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.portfolio.workingcapitalloan.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WorkingCapitalLoanTransactionMapperTest { + + private final WorkingCapitalLoanTransactionMapper mapper = Mappers.getMapper(WorkingCapitalLoanTransactionMapper.class); + + @Mock + private WorkingCapitalLoanTransaction transaction; + + @Mock + private WorkingCapitalLoanTransactionAllocation allocation; + + @Test + void toData_mapsAllFieldsIncludingAllocationPortions() { + final LocalDate txnDate = LocalDate.of(2024, 2, 1); + final BigDecimal amount = BigDecimal.valueOf(10000); + when(transaction.getId()).thenReturn(1L); + when(transaction.getTransactionType()).thenReturn(LoanTransactionType.DISBURSEMENT); + when(transaction.getDateOf()).thenReturn(txnDate); + when(transaction.getSubmittedOnDate()).thenReturn(txnDate); + when(transaction.getTransactionAmount()).thenReturn(amount); + when(transaction.getExternalId()).thenReturn(new ExternalId("ext-1")); + when(transaction.isReversed()).thenReturn(false); + when(transaction.getReversalExternalId()).thenReturn(null); + when(transaction.getReversedOnDate()).thenReturn(null); + when(transaction.getAllocation()).thenReturn(allocation); + when(allocation.getPrincipalPortion()).thenReturn(amount); + when(allocation.getFeeChargesPortion()).thenReturn(null); + when(allocation.getPenaltyChargesPortion()).thenReturn(null); + + final WorkingCapitalLoanTransactionData data = mapper.toData(transaction); + + assertNotNull(data); + assertEquals(1L, data.getId()); + assertNotNull(data.getType()); + assertEquals(LoanTransactionType.DISBURSEMENT.getValue().longValue(), data.getType().getId()); + assertEquals(LoanTransactionType.DISBURSEMENT.getCode(), data.getType().getCode()); + assertEquals(txnDate, data.getTransactionDate()); + assertEquals(txnDate, data.getSubmittedOnDate()); + assertEquals(amount, data.getTransactionAmount()); + assertEquals(amount, data.getPrincipalPortion()); + assertNull(data.getFeeChargesPortion()); + assertNull(data.getPenaltyChargesPortion()); + assertEquals(false, data.getReversed()); + } + + @Test + void toData_whenAllocationNull_setsPortionsToNull() { + when(transaction.getId()).thenReturn(2L); + when(transaction.getTransactionType()).thenReturn(LoanTransactionType.DISBURSEMENT); + when(transaction.getDateOf()).thenReturn(LocalDate.of(2024, 2, 1)); + when(transaction.getSubmittedOnDate()).thenReturn(LocalDate.of(2024, 2, 1)); + when(transaction.getTransactionAmount()).thenReturn(BigDecimal.valueOf(5000)); + when(transaction.getExternalId()).thenReturn(null); + when(transaction.isReversed()).thenReturn(false); + when(transaction.getReversalExternalId()).thenReturn(null); + when(transaction.getReversedOnDate()).thenReturn(null); + when(transaction.getAllocation()).thenReturn(null); + + final WorkingCapitalLoanTransactionData data = mapper.toData(transaction); + + assertNotNull(data); + assertNotNull(data.getType()); + assertEquals(LoanTransactionType.DISBURSEMENT.getCode(), data.getType().getCode()); + assertNull(data.getPrincipalPortion()); + assertNull(data.getFeeChargesPortion()); + assertNull(data.getPenaltyChargesPortion()); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java new file mode 100644 index 00000000000..335fef3667b --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDisbursementTest.java @@ -0,0 +1,1086 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloan.WorkingCapitalLoanDisbursementTestBuilder; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductHelper; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class WorkingCapitalLoanDisbursementTest { + + private final WorkingCapitalLoanApplicationHelper applicationHelper = new WorkingCapitalLoanApplicationHelper(); + private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); + + private final List createdLoanIds = new ArrayList<>(); + private final List createdProductIds = new ArrayList<>(); + private final Long createdClientId = createClient(); + + private static final String CLEANUP_EMPTY_COMMAND_JSON = "{\"locale\":\"en\",\"dateFormat\":\"yyyy-MM-dd\"}"; + + @AfterEach + void cleanupEntities() { + // Loans: undo disbursal -> undo approval -> delete + for (final Long loanId : createdLoanIds) { + if (loanId == null) { + continue; + } + try { + applicationHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be disbursed / client inactive / loan already removed) + } + try { + applicationHelper.undoApprovalById(loanId, CLEANUP_EMPTY_COMMAND_JSON); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may not be approved / already removed) + } + try { + applicationHelper.deleteById(loanId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (loan may be in non-deletable state / already removed) + } + } + createdLoanIds.clear(); + + // Products + for (final Long productId : createdProductIds) { + if (productId == null) { + continue; + } + try { + productHelper.deleteWorkingCapitalLoanProductById(productId); + } catch (final CallFailedRuntimeException ignored) { + // best-effort cleanup (product may be already removed) + } + } + createdProductIds.clear(); + } + + @Test + public void testDisburseWorkingCapitalLoan() { + final Long productId = createProduct(); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final LocalDate actualDisbursementDate = LocalDate.now(ZoneId.systemDefault()); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDisbursementDate, + BigDecimal.valueOf(5000)); + applicationHelper.disburseById(loanId, disburseJson); + + final String response = applicationHelper.retrieveById(loanId); + assertNotNull(response); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + assertStatus(data, "loanStatusType.active"); + assertTrue(data.has("balance") && !data.get("balance").isJsonNull(), "GET loan after disburse should include balance"); + final JsonObject balance = data.getAsJsonObject("balance"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), balance.get("principalOutstanding")); + + assertTrue(data.has("disbursementDetails") && data.get("disbursementDetails").isJsonArray(), + "GET loan after disburse should include disbursementDetails array"); + assertFalse(data.getAsJsonArray("disbursementDetails").isEmpty(), "disbursementDetails should not be empty"); + final JsonObject disbursement = data.getAsJsonArray("disbursementDetails").get(0).getAsJsonObject(); + assertTrue(disbursement.has("actualDisbursementDate")); + assertDateEquals(actualDisbursementDate, disbursement.get("actualDisbursementDate")); + assertTrue(disbursement.has("actualAmount")); + assertEqualBigDecimal(BigDecimal.valueOf(5000), disbursement.get("actualAmount")); + + assertTrue(data.has("transactions"), "GET loan after disburse should include transactions"); + assertTrue(data.get("transactions").isJsonArray()); + assertEquals(1, data.getAsJsonArray("transactions").size(), "After disburse there should be one transaction"); + final JsonObject txn = data.getAsJsonArray("transactions").get(0).getAsJsonObject(); + assertTrue(txn.has("type") && txn.has("transactionAmount")); + assertEquals("loanTransactionType.disbursement", txn.getAsJsonObject("type").get("code").getAsString()); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("transactionAmount")); + assertTrue(txn.has("reversed") && !txn.get("reversed").getAsBoolean(), "Disbursement transaction should not be reversed"); + assertTrue(txn.has("principalPortion"), "Transaction should include allocation principalPortion"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("principalPortion")); + assertTrue(txn.has("feeChargesPortion"), "Transaction should include allocation feeChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("feeChargesPortion")); + assertTrue(txn.has("penaltyChargesPortion"), "Transaction should include allocation penaltyChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("penaltyChargesPortion")); + } + + @Test + public void testDisburseWithAllRequestFieldsAndVerifyResponse() { + final Long productId = createProductWithDiscountAllowed(); + + final BigDecimal approvedPrincipal = BigDecimal.valueOf(10000); + final BigDecimal approvedDiscount = BigDecimal.valueOf(50); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(approvedPrincipal) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, approvedPrincipal, approvedDiscount)); + + final LocalDate actualDisbursementDate = LocalDate.now(ZoneId.systemDefault()); + final BigDecimal transactionAmount = BigDecimal.valueOf(8000); + final BigDecimal discountAmount = BigDecimal.valueOf(30); + final String note = "Disbursal note for test"; + final Integer paymentTypeId = 1; + final String accountNumber = "acc-" + UUID.randomUUID().toString().substring(0, 8); + final String checkNumber = "chk-123"; + final String routingCode = "rte-456"; + final String receiptNumber = "rec-789"; + final String bankNumber = "bnk-001"; + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDisbursementDate, transactionAmount, + discountAmount, note, paymentTypeId, accountNumber, checkNumber, routingCode, receiptNumber, bankNumber); + applicationHelper.disburseById(loanId, disburseJson); + + final String response = applicationHelper.retrieveById(loanId); + assertNotNull(response); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + + assertStatus(data, "loanStatusType.active"); + assertTrue(data.has("balance") && !data.get("balance").isJsonNull(), "GET loan after disburse should include balance"); + assertEqualBigDecimal(transactionAmount, data.getAsJsonObject("balance").get("principalOutstanding")); + assertEqualBigDecimal(discountAmount, data.get("discount")); + assertTrue(data.has("id")); + assertEquals(loanId.longValue(), data.get("id").getAsLong()); + assertTrue(data.has("client") && !data.get("client").isJsonNull()); + assertTrue(data.has("product") && !data.get("product").isJsonNull()); + + if (data.has("timeline") && !data.get("timeline").isJsonNull()) { + final JsonObject timeline = data.getAsJsonObject("timeline"); + assertTrue(timeline.has("actualDisbursementDate")); + assertDateEquals(actualDisbursementDate, timeline.get("actualDisbursementDate")); + assertTrue(timeline.has("approvedOnDate")); + assertTrue(timeline.has("actualMaturityDate"), "timeline should include actualMaturityDate (null until fully paid)"); + assertTrue(timeline.get("actualMaturityDate").isJsonNull() || timeline.get("actualMaturityDate") == null, + "Expected actualMaturityDate to be null after disbursement"); + assertTrue(timeline.has("disbursementDetails") && timeline.get("disbursementDetails").isJsonArray(), + "timeline should include disbursementDetails list"); + assertFalse(timeline.getAsJsonArray("disbursementDetails").isEmpty(), "timeline disbursementDetails should not be empty"); + } + assertTrue(data.has("disbursementDetails") && data.get("disbursementDetails").isJsonArray(), + "GET loan after disburse should include disbursementDetails array"); + assertFalse(data.getAsJsonArray("disbursementDetails").isEmpty(), "disbursementDetails should not be empty"); + final JsonObject disbursement = data.getAsJsonArray("disbursementDetails").get(0).getAsJsonObject(); + assertTrue(disbursement.has("expectedDisbursementDate"), "disbursementDetails should include expectedDisbursementDate"); + assertTrue(disbursement.has("expectedAmount"), "disbursementDetails should include expectedAmount"); + assertTrue(disbursement.has("actualDisbursementDate")); + assertDateEquals(actualDisbursementDate, disbursement.get("actualDisbursementDate")); + assertTrue(disbursement.has("actualAmount")); + assertEqualBigDecimal(transactionAmount, disbursement.get("actualAmount")); + + assertTrue(data.has("transactions") && data.get("transactions").isJsonArray()); + assertEquals(1, data.getAsJsonArray("transactions").size()); + final JsonObject txn = data.getAsJsonArray("transactions").get(0).getAsJsonObject(); + assertEqualBigDecimal(transactionAmount, txn.get("transactionAmount")); + assertTrue(txn.has("principalPortion"), "Transaction should include allocation principalPortion"); + assertEqualBigDecimal(transactionAmount, txn.get("principalPortion")); + assertTrue(txn.has("feeChargesPortion"), "Transaction should include allocation feeChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("feeChargesPortion")); + assertTrue(txn.has("penaltyChargesPortion"), "Transaction should include allocation penaltyChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("penaltyChargesPortion")); + assertTrue(txn.has("paymentDetailData") && !txn.get("paymentDetailData").isJsonNull(), + "Transaction should include paymentDetailData"); + final JsonObject paymentDetailData = txn.getAsJsonObject("paymentDetailData"); + assertEquals(accountNumber, paymentDetailData.get("accountNumber").getAsString()); + assertEquals(checkNumber, paymentDetailData.get("checkNumber").getAsString()); + assertEquals(routingCode, paymentDetailData.get("routingCode").getAsString()); + assertEquals(receiptNumber, paymentDetailData.get("receiptNumber").getAsString()); + assertEquals(bankNumber, paymentDetailData.get("bankNumber").getAsString()); + } + + @Test + public void testUndoDisburseWorkingCapitalLoan() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final LocalDate actualDisbursementDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDisbursementDate, BigDecimal.valueOf(5000))); + + applicationHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + + final String response = applicationHelper.retrieveById(loanId); + assertNotNull(response); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + assertStatus(data, "loanStatusType.approved"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), data.get("approvedPrincipal")); + assertTrue(data.has("balance") && !data.get("balance").isJsonNull(), "GET loan after undo should include balance"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), data.getAsJsonObject("balance").get("principalOutstanding")); + + assertTrue(data.has("disbursementDetails") && data.get("disbursementDetails").isJsonArray(), + "GET loan after undo should include disbursementDetails array"); + assertFalse(data.getAsJsonArray("disbursementDetails").isEmpty(), "disbursementDetails should not be empty"); + final JsonObject disbursement = data.getAsJsonArray("disbursementDetails").get(0).getAsJsonObject(); + assertTrue(!disbursement.has("actualDisbursementDate") || disbursement.get("actualDisbursementDate").isJsonNull(), + "Expected actualDisbursementDate to be absent or null after undo"); + assertTrue(!disbursement.has("actualAmount") || disbursement.get("actualAmount").isJsonNull(), + "Expected actualAmount to be absent or null after undo"); + assertTrue(data.has("timeline") && !data.get("timeline").isJsonNull(), "GET loan after undo should include timeline"); + final JsonObject timeline = data.getAsJsonObject("timeline"); + assertTrue(timeline.has("actualMaturityDate"), "timeline should include actualMaturityDate (null until fully paid)"); + assertTrue(timeline.get("actualMaturityDate").isJsonNull() || timeline.get("actualMaturityDate") == null, + "Expected actualMaturityDate to be null after undo"); + + assertTrue(data.has("transactions") && data.get("transactions").isJsonArray(), "Expected transactions array in response"); + assertEquals(1, data.getAsJsonArray("transactions").size(), "Undo disburse should keep transaction history"); + final JsonObject txn = data.getAsJsonArray("transactions").get(0).getAsJsonObject(); + assertTrue(txn.has("reversed") && txn.get("reversed").getAsBoolean(), "Expected transaction to be reversed"); + } + + @Test + public void testUndoDisbursalWithNote() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + applicationHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson("Undo disbursal note")); + + final String response = applicationHelper.retrieveById(loanId); + assertNotNull(response); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + assertStatus(data, "loanStatusType.approved"); + } + + @Test + public void testDisburseWithMissingActualDisbursementDate() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(null, BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("actualDisbursementDate") + && (ex.getDeveloperMessage().contains("mandatory") || ex.getDeveloperMessage().contains("null"))); + } + + @Test + public void testDisburseWithMissingTransactionAmount() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue( + ex.getDeveloperMessage().contains("transactionAmount") + && (ex.getDeveloperMessage().contains("mandatory") || ex.getDeveloperMessage().contains("null")), + "Expected message about mandatory transactionAmount: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithTransactionAmountExceedingApproved() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(6000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("amount.cannot.exceed.approved.principal")); + } + + @Test + public void testDisburseWithNegativeTransactionAmount() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(-100)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().toLowerCase().contains("transactionamount") || ex.getDeveloperMessage().contains("positive") + || ex.getDeveloperMessage().contains("greater")); + } + + @Test + public void testDisburseWithFutureDate() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final LocalDate futureDate = LocalDate.now(ZoneId.systemDefault()).plusDays(30); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(futureDate, BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("future.date") || ex.getDeveloperMessage().contains("actualDisbursementDate")); + } + + @Test + public void testDisburseWithDateBeforeApproval() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final LocalDate beforeApproval = approvedOnDate.minusDays(1); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(beforeApproval, BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("before.approval") || ex.getDeveloperMessage().contains("actualDisbursementDate")); + } + + @Test + public void testDisburseWithActualDateBeforeSubmittedDate() { + final Long productId = createProduct(); + + final LocalDate submittedOnDate = LocalDate.now(ZoneId.systemDefault()); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withSubmittedOnDate(submittedOnDate) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(submittedOnDate, BigDecimal.valueOf(5000), null)); + + final LocalDate actualDateBeforeSubmitted = submittedOnDate.minusDays(1); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDateBeforeSubmitted, + BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("submitted") || ex.getDeveloperMessage().contains("actualDisbursementDate"), + "Expected message about actual date before submitted: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithNoteExceedingLength() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + + final String longNote = "a".repeat(1001); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000), null, longNote, null, null, null, null, null, null, null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("note") || ex.getDeveloperMessage().toLowerCase().contains("length"), + "Expected message about note length: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithDiscountExceedingCreated() { + final Long productId = createProductWithDiscountAllowed(); + + final BigDecimal approvedPrincipal = BigDecimal.valueOf(5000); + final BigDecimal approvedDiscount = BigDecimal.valueOf(20); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(approvedPrincipal) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), approvedPrincipal, approvedDiscount)); + + final BigDecimal discountAmountExceeding = BigDecimal.valueOf(25); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + approvedPrincipal, discountAmountExceeding, null, null, null, null, null, null, null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("discount") && ex.getDeveloperMessage().contains("exceed")); + } + + @Test + public void testDisburseWithDuplicateTransactionExternalId() { + final Long productId = createProduct(); + + final String sharedExternalId = "wcl-txn-ext-" + UUID.randomUUID(); + + final Long loanId1 = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + applicationHelper.approveById(loanId1, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + applicationHelper.disburseById(loanId1, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), + null, null, null, null, null, null, null, null, sharedExternalId)); + + final Long loanId2 = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(3000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + applicationHelper.approveById(loanId2, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(3000), null)); + + final String disburseJson2 = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(3000), null, null, null, null, null, null, null, null, sharedExternalId); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId2, disburseJson2); + assertEquals(400, ex.getStatus()); + assertTrue(ex.getDeveloperMessage().contains("externalId") && ex.getDeveloperMessage().toLowerCase().contains("already"), + "Expected duplicate transaction externalId error: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWhenLoanNotApproved() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("Transition") || ex.getDeveloperMessage().contains("not allowed") + || ex.getDeveloperMessage().contains("status")); + } + + @Test + public void testDisburseNonExistentLoan() { + final long nonExistentLoanId = 999_999_999L; + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000)); + final CallFailedRuntimeException ex = assertThrows(CallFailedRuntimeException.class, + () -> applicationHelper.disburseById(nonExistentLoanId, disburseJson)); + assertEquals(404, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + } + + @Test + public void testUndoDisbursalWhenLoanNotDisbursed() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final LocalDate approvedOnDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.approveById(loanId, + WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(approvedOnDate, BigDecimal.valueOf(5000), null)); + + final CallFailedRuntimeException ex = applicationHelper.runUndoDisbursalExpectingFailure(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("Transition") || ex.getDeveloperMessage().contains("not allowed") + || ex.getDeveloperMessage().contains("status")); + } + + @Test + public void testUndoDisbursalNonExistentLoan() { + final long nonExistentLoanId = 999_999_999L; + final CallFailedRuntimeException ex = assertThrows(CallFailedRuntimeException.class, () -> applicationHelper + .undoDisbursalById(nonExistentLoanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson())); + assertEquals(404, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + } + + @Test + public void testUndoDisbursalWithNoteExceedingLength() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + final String longNote = "a".repeat(1001); + final CallFailedRuntimeException ex = applicationHelper.runUndoDisbursalExpectingFailure(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson(longNote)); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("note") || ex.getDeveloperMessage().toLowerCase().contains("length"), + "Expected message about note length: " + ex.getDeveloperMessage()); + } + + @Test + public void testGetTransactionsListAfterDisburse() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + final String json = applicationHelper.retrieveTransactionsByLoanIdRaw(loanId); + assertNotNull(json); + final JsonObject page = JsonParser.parseString(json).getAsJsonObject(); + assertTrue(page.has("content"), "Response should have content array"); + assertTrue(page.has("totalElements")); + final JsonArray content = page.getAsJsonArray("content"); + assertEquals(1, content.size(), "After one disburse there should be one transaction"); + assertEquals(1L, page.get("totalElements").getAsLong()); + final JsonObject txn = content.get(0).getAsJsonObject(); + assertTrue(txn.has("id") && txn.has("type") && txn.has("transactionAmount")); + assertEquals("loanTransactionType.disbursement", txn.getAsJsonObject("type").get("code").getAsString()); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("transactionAmount")); + assertTrue(txn.has("principalPortion")); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("principalPortion")); + assertTrue(txn.has("feeChargesPortion")); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("feeChargesPortion")); + assertTrue(txn.has("penaltyChargesPortion")); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("penaltyChargesPortion")); + assertFalse(txn.get("reversed").getAsBoolean()); + } + + @Test + public void testGetTransactionByIdAfterDisburse() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(6000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(6000), null)); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(6000))); + + final String listJson = applicationHelper.retrieveTransactionsByLoanIdRaw(loanId); + final JsonArray content = JsonParser.parseString(listJson).getAsJsonObject().getAsJsonArray("content"); + assertEquals(1, content.size()); + final long transactionId = content.get(0).getAsJsonObject().get("id").getAsLong(); + + final String txnJson = applicationHelper.retrieveTransactionByLoanIdAndTransactionIdRaw(loanId, transactionId); + final JsonObject txn = JsonParser.parseString(txnJson).getAsJsonObject(); + assertEquals(transactionId, txn.get("id").getAsLong()); + assertEqualBigDecimal(BigDecimal.valueOf(6000), txn.get("transactionAmount")); + assertEqualBigDecimal(BigDecimal.valueOf(6000), txn.get("principalPortion")); + assertTrue(txn.has("transactionDate") && txn.has("reversed")); + assertTrue(txn.has("type"), "GET transaction should include type"); + assertEquals("loanTransactionType.disbursement", txn.getAsJsonObject("type").get("code").getAsString()); + assertTrue(txn.has("submittedOnDate"), "GET transaction should include submittedOnDate"); + assertFalse(txn.has("interestPortion"), "WCL has no interest"); + assertTrue(txn.has("feeChargesPortion"), "GET transaction should include allocation feeChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("feeChargesPortion")); + assertTrue(txn.has("penaltyChargesPortion"), "GET transaction should include allocation penaltyChargesPortion"); + assertEqualBigDecimal(BigDecimal.ZERO, txn.get("penaltyChargesPortion")); + + } + + @Test + public void testGetTransactionsListEmptyWhenNotDisbursed() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + + final String json = applicationHelper.retrieveTransactionsByLoanIdRaw(loanId); + final JsonObject page = JsonParser.parseString(json).getAsJsonObject(); + assertTrue(page.has("content")); + assertTrue(page.getAsJsonArray("content").isEmpty(), "Before disburse transactions list should be empty"); + + } + + @Test + public void testGetTransactionByNonExistentIdReturns404() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + final long nonExistentTransactionId = 999_999L; + final CallFailedRuntimeException ex = applicationHelper.runRetrieveTransactionByLoanIdAndTransactionIdExpectingFailure(loanId, + nonExistentTransactionId); + assertEquals(404, ex.getStatus()); + } + + @Test + public void testGetTransactionsByLoanExternalId() { + final Long productId = createProduct(); + + final String loanExternalId = "wcl-loan-ext-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withExternalId(loanExternalId) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000))); + + final String json = applicationHelper.retrieveTransactionsByLoanExternalIdRaw(loanExternalId); + assertNotNull(json); + final JsonObject page = JsonParser.parseString(json).getAsJsonObject(); + assertTrue(page.has("content") && page.has("totalElements")); + final JsonArray content = page.getAsJsonArray("content"); + assertEquals(1, content.size()); + final JsonObject txn = content.get(0).getAsJsonObject(); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("transactionAmount")); + assertEqualBigDecimal(BigDecimal.valueOf(5000), txn.get("principalPortion")); + } + + @Test + public void testGetTransactionByLoanIdAndTransactionExternalId() { + final Long productId = createProduct(); + + final String txnExternalId = "wcl-txn-ext-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(7000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(7000), null)); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(7000), + null, null, null, null, null, null, null, null, txnExternalId)); + + final String txnJson = applicationHelper.retrieveTransactionByLoanIdAndTransactionExternalIdRaw(loanId, txnExternalId); + final JsonObject txn = JsonParser.parseString(txnJson).getAsJsonObject(); + assertEqualBigDecimal(BigDecimal.valueOf(7000), txn.get("transactionAmount")); + assertEqualBigDecimal(BigDecimal.valueOf(7000), txn.get("principalPortion")); + assertTrue(txn.has("externalId") && txnExternalId.equals(txn.get("externalId").getAsString())); + } + + @Test + public void testStateTransitionByLoanExternalId_ApproveAndDisburse() { + final Long productId = createProduct(); + + final String loanExternalId = "wcl-ext-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withExternalId(loanExternalId) // + .buildSubmitJson()); + + final String approveJson = WorkingCapitalLoanApplicationTestBuilder.buildApproveJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000), null); + applicationHelper.approveByExternalId(loanExternalId, approveJson); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000)); + applicationHelper.disburseByExternalId(loanExternalId, disburseJson); + + final String response = applicationHelper.retrieveById(loanId); + final JsonObject data = JsonParser.parseString(response).getAsJsonObject(); + assertStatus(data, "loanStatusType.active"); + assertTrue(data.has("balance") && !data.get("balance").isJsonNull(), "GET loan after disburse should include balance"); + assertEqualBigDecimal(BigDecimal.valueOf(5000), data.getAsJsonObject("balance").get("principalOutstanding")); + } + + @Test + public void testGetTransactionByExternalLoanIdAndTransactionId() { + final Long productId = createProduct(); + + final String loanExternalId = "wcl-lext-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(8000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withExternalId(loanExternalId) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(8000), null)); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder + .buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(8000))); + + final String listJson = applicationHelper.retrieveTransactionsByLoanIdRaw(loanId); + final long transactionId = JsonParser.parseString(listJson).getAsJsonObject().getAsJsonArray("content").get(0).getAsJsonObject() + .get("id").getAsLong(); + + final String txnJson = applicationHelper.retrieveTransactionByExternalLoanIdAndTransactionIdRaw(loanExternalId, transactionId); + final JsonObject txn = JsonParser.parseString(txnJson).getAsJsonObject(); + assertEquals(transactionId, txn.get("id").getAsLong()); + assertEqualBigDecimal(BigDecimal.valueOf(8000), txn.get("transactionAmount")); + } + + @Test + public void testGetTransactionByExternalLoanIdAndTransactionExternalId() { + final Long productId = createProduct(); + + final String loanExternalId = "wcl-lext2-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final String txnExternalId = "wcl-text-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(9000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .withExternalId(loanExternalId) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(9000), null)); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(9000), + null, null, null, null, null, null, null, null, txnExternalId)); + + final String txnJson = applicationHelper.retrieveTransactionByExternalLoanIdAndTransactionExternalIdRaw(loanExternalId, + txnExternalId); + final JsonObject txn = JsonParser.parseString(txnJson).getAsJsonObject(); + assertEqualBigDecimal(BigDecimal.valueOf(9000), txn.get("transactionAmount")); + assertEquals(txnExternalId, txn.get("externalId").getAsString()); + } + + @Test + public void testDisburseWithInvalidPaymentTypeId() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000), null, null, 0, null, null, null, null, null, null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("paymentTypeId") || ex.getDeveloperMessage().toLowerCase().contains("payment"), + "Expected message about invalid paymentTypeId: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseWithPaymentDetailsStringExceedingLength() { + final Long productId = createProduct(); + + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + + final String longAccountNumber = "a".repeat(51); + final String disburseJson = WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(LocalDate.now(ZoneId.systemDefault()), + BigDecimal.valueOf(5000), null, null, null, longAccountNumber, null, null, null, null, null); + final CallFailedRuntimeException ex = applicationHelper.runDisburseExpectingFailure(loanId, disburseJson); + assertEquals(400, ex.getStatus()); + assertNotNull(ex.getDeveloperMessage()); + assertTrue(ex.getDeveloperMessage().contains("accountNumber") || ex.getDeveloperMessage().toLowerCase().contains("length"), + "Expected message about accountNumber length: " + ex.getDeveloperMessage()); + } + + @Test + public void testDisburseGeneratesAmortizationSchedule() { + final Long productId = createProductWithDiscountAllowed(); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + + final LocalDate disbursementDate = LocalDate.now(ZoneId.systemDefault()); + final BigDecimal disbursementAmount = BigDecimal.valueOf(5000); + final BigDecimal discountAmount = BigDecimal.valueOf(25); + applicationHelper.disburseById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(disbursementDate, + disbursementAmount, discountAmount, null, null, null, null, null, null, null)); + + final JsonObject schedule = retrieveAmortizationScheduleByLoanId(loanId); + assertDateEquals(disbursementDate, schedule.get("expectedDisbursementDate")); + assertEqualBigDecimal(disbursementAmount, schedule.get("netDisbursementAmount")); + assertEqualBigDecimal(discountAmount, schedule.get("originationFeeAmount")); + assertTrue(schedule.has("payments") && schedule.get("payments").isJsonArray(), "Schedule should contain payments"); + assertFalse(schedule.getAsJsonArray("payments").isEmpty(), "Schedule payments should not be empty after disburse"); + } + + @Test + public void testUndoDisbursalRegeneratesAmortizationScheduleToExpectedDate() { + final Long productId = createProduct(); + final Long loanId = submitAndTrack(new WorkingCapitalLoanApplicationTestBuilder() // + .withClientId(createdClientId) // + .withProductId(productId) // + .withPrincipal(BigDecimal.valueOf(5000)) // + .withPeriodPaymentRate(BigDecimal.ONE) // + .buildSubmitJson()); + + final JsonObject beforeDisburse = JsonParser.parseString(applicationHelper.retrieveById(loanId)).getAsJsonObject(); + final JsonObject firstDisbursementDetail = beforeDisburse.getAsJsonArray("disbursementDetails").get(0).getAsJsonObject(); + final LocalDate expectedDateBeforeDisburse = parseDate(firstDisbursementDetail.get("expectedDisbursementDate")); + + applicationHelper.approveById(loanId, WorkingCapitalLoanApplicationTestBuilder + .buildApproveJson(LocalDate.now(ZoneId.systemDefault()), BigDecimal.valueOf(5000), null)); + + final LocalDate actualDisbursementDate = LocalDate.now(ZoneId.systemDefault()); + applicationHelper.disburseById(loanId, + WorkingCapitalLoanDisbursementTestBuilder.buildDisburseJson(actualDisbursementDate, BigDecimal.valueOf(5000))); + final JsonObject scheduleAfterDisburse = retrieveAmortizationScheduleByLoanId(loanId); + assertDateEquals(actualDisbursementDate, scheduleAfterDisburse.get("expectedDisbursementDate")); + + applicationHelper.undoDisbursalById(loanId, WorkingCapitalLoanDisbursementTestBuilder.buildUndoDisburseJson()); + + final JsonObject scheduleAfterUndo = retrieveAmortizationScheduleByLoanId(loanId); + assertDateEquals(expectedDateBeforeDisburse, scheduleAfterUndo.get("expectedDisbursementDate")); + assertTrue(scheduleAfterUndo.has("payments") && scheduleAfterUndo.get("payments").isJsonArray(), + "Schedule should still exist after undo"); + assertFalse(scheduleAfterUndo.getAsJsonArray("payments").isEmpty(), "Schedule payments should not be empty after undo"); + } + + private static void assertStatus(final JsonObject data, final String expectedStatusCode) { + assertTrue(data.has("status") && !data.get("status").isJsonNull()); + assertEquals(expectedStatusCode, data.getAsJsonObject("status").get("code").getAsString()); + } + + private static void assertEqualBigDecimal(final BigDecimal expected, final JsonElement actual) { + assertNotNull(actual, "Expected value for field"); + assertFalse(actual.isJsonNull(), "Expected non-null value"); + assertEquals(0, expected.compareTo(actual.getAsJsonPrimitive().getAsBigDecimal()), + "Expected " + expected + " but got " + actual.getAsString()); + } + + private static void assertDateEquals(final LocalDate expected, final JsonElement actual) { + assertNotNull(actual, "Expected date value"); + assertFalse(actual.isJsonNull(), "Expected non-null date"); + if (actual.isJsonArray()) { + final JsonArray arr = actual.getAsJsonArray(); + assertEquals(expected.getYear(), arr.get(0).getAsInt()); + assertEquals(expected.getMonthValue(), arr.get(1).getAsInt()); + assertEquals(expected.getDayOfMonth(), arr.get(2).getAsInt()); + } else { + assertEquals(expected.format(DateTimeFormatter.ISO_LOCAL_DATE), actual.getAsString()); + } + } + + private Long createProduct() { + final String uniqueName = "WCL Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + final Long productId = productHelper + .createWorkingCapitalLoanProduct( + new WorkingCapitalLoanProductTestBuilder().withName(uniqueName).withShortName(uniqueShortName).build()) + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createProductWithDiscountAllowed() { + final String uniqueName = "WCL Product " + UUID.randomUUID().toString().substring(0, 8); + final String uniqueShortName = UUID.randomUUID().toString().replace("-", "").substring(0, 4); + final Long productId = productHelper.createWorkingCapitalLoanProduct(new WorkingCapitalLoanProductTestBuilder() // + .withName(uniqueName) // + .withShortName(uniqueShortName) // + .withAllowAttributeOverrides(java.util.Map.of("discountDefault", Boolean.TRUE)) // + .build()) // + .getResourceId(); + createdProductIds.add(productId); + return productId; + } + + private Long createClient() { + return ClientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + } + + private JsonObject retrieveAmortizationScheduleByLoanId(final Long loanId) { + final String json = applicationHelper.retrieveAmortizationScheduleByLoanIdRaw(loanId); + return JsonParser.parseString(json).getAsJsonObject(); + } + + private static LocalDate parseDate(final JsonElement dateElement) { + if (dateElement == null || dateElement.isJsonNull()) { + return null; + } + if (dateElement.isJsonArray()) { + final JsonArray arr = dateElement.getAsJsonArray(); + return LocalDate.of(arr.get(0).getAsInt(), arr.get(1).getAsInt(), arr.get(2).getAsInt()); + } + return LocalDate.parse(dateElement.getAsString()); + } + + private Long submitAndTrack(final String submitJson) { + final Long loanId = applicationHelper.submit(submitJson); + createdLoanIds.add(loanId); + return loanId; + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationHelper.java index de4853ce7de..7239858d32e 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanApplicationHelper.java @@ -18,10 +18,12 @@ */ package org.apache.fineract.integrationtests.common.workingcapitalloan; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Map; import org.apache.fineract.client.feign.ObjectMapperFactory; +import org.apache.fineract.client.feign.services.WorkingCapitalLoanTransactionsApi; import org.apache.fineract.client.feign.services.WorkingCapitalLoansApi; import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.feign.util.FeignCalls; @@ -38,6 +40,8 @@ public class WorkingCapitalLoanApplicationHelper { private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getShared(); + private static final ObjectMapper RESPONSE_OBJECT_MAPPER = ObjectMapperFactory.getShared().copy() + .setSerializationInclusion(JsonInclude.Include.ALWAYS); public WorkingCapitalLoanApplicationHelper() {} @@ -45,6 +49,10 @@ private static WorkingCapitalLoansApi api() { return FineractFeignClientHelper.getFineractFeignClient().workingCapitalLoans(); } + private static WorkingCapitalLoanTransactionsApi transactionsApi() { + return FineractFeignClientHelper.getFineractFeignClient().create(WorkingCapitalLoanTransactionsApi.class); + } + public Long submit(final String jsonBody) { PostWorkingCapitalLoansRequest request = fromJson(jsonBody, PostWorkingCapitalLoansRequest.class); PostWorkingCapitalLoansResponse response = FeignCalls.ok(() -> api().submitWorkingCapitalLoanApplication(request)); @@ -79,6 +87,11 @@ public String retrieveByExternalId(final String externalId) { return toJson(response); } + public String retrieveAmortizationScheduleByLoanIdRaw(final Long loanId) { + Object response = FeignCalls.ok(() -> api().retrieveAmortizationSchedule(loanId)); + return toJson(response); + } + public String retrieveAllPagedRaw(final Map queryParams) { Map params = queryParams != null ? queryParams : Map.of(); Object response = FeignCalls.ok(() -> api().retrieveAllWorkingCapitalLoans(params)); @@ -122,6 +135,60 @@ public Long undoApprovalByExternalId(final String externalId, final String jsonB .getResourceId(); } + public Long disburseById(final Long loanId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.ok(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "disburse", request)).getResourceId(); + } + + public Long disburseByExternalId(final String externalId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.ok(() -> api().stateTransitionWorkingCapitalLoanByExternalId(externalId, "disburse", request)).getResourceId(); + } + + public Long undoDisbursalById(final Long loanId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.ok(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "undodisbursal", request)).getResourceId(); + } + + public String retrieveTransactionsByLoanIdRaw(final Long loanId) { + Object response = FeignCalls.ok(() -> transactionsApi().retrieveWorkingCapitalLoanTransactionsById(loanId)); + return toJson(response); + } + + public String retrieveTransactionsByLoanExternalIdRaw(final String loanExternalId) { + Object response = FeignCalls.ok(() -> transactionsApi().retrieveWorkingCapitalLoanTransactionsByExternalId(loanExternalId)); + return toJson(response); + } + + public String retrieveTransactionByLoanIdAndTransactionIdRaw(final Long loanId, final Long transactionId) { + Object response = FeignCalls.ok(() -> transactionsApi().retrieveWorkingCapitalLoanTransactionById(loanId, transactionId)); + return toJson(response); + } + + public CallFailedRuntimeException runRetrieveTransactionByLoanIdAndTransactionIdExpectingFailure(final Long loanId, + final Long transactionId) { + return FeignCalls.fail(() -> transactionsApi().retrieveWorkingCapitalLoanTransactionById(loanId, transactionId)); + } + + public String retrieveTransactionByLoanIdAndTransactionExternalIdRaw(final Long loanId, final String externalTransactionId) { + Object response = FeignCalls + .ok(() -> transactionsApi().retrieveWorkingCapitalLoanTransactionByExternalTransactionId(loanId, externalTransactionId)); + return toJson(response); + } + + public String retrieveTransactionByExternalLoanIdAndTransactionIdRaw(final String loanExternalId, final Long transactionId) { + Object response = FeignCalls.ok(() -> transactionsApi() + .retrieveWorkingCapitalLoanTransactionByExternalLoanIdAndTransactionId(loanExternalId, transactionId)); + return toJson(response); + } + + public String retrieveTransactionByExternalLoanIdAndTransactionExternalIdRaw(final String loanExternalId, + final String externalTransactionId) { + Object response = FeignCalls.ok(() -> transactionsApi() + .retrieveWorkingCapitalLoanTransactionByExternalLoanIdAndExternalTransactionId(loanExternalId, externalTransactionId)); + return toJson(response); + } + public CallFailedRuntimeException runApproveExpectingFailure(final Long loanId, final String jsonBody) { PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); return FeignCalls.fail(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "approve", request)); @@ -137,6 +204,16 @@ public CallFailedRuntimeException runUndoApprovalExpectingFailure(final Long loa return FeignCalls.fail(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "undoapproval", request)); } + public CallFailedRuntimeException runDisburseExpectingFailure(final Long loanId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.fail(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "disburse", request)); + } + + public CallFailedRuntimeException runUndoDisbursalExpectingFailure(final Long loanId, final String jsonBody) { + PostWorkingCapitalLoansLoanIdRequest request = fromJson(jsonBody, PostWorkingCapitalLoansLoanIdRequest.class); + return FeignCalls.fail(() -> api().stateTransitionWorkingCapitalLoanById(loanId, "undodisbursal", request)); + } + /** * For validation tests: run submit expecting failure. */ @@ -163,7 +240,7 @@ private static T fromJson(String json, Class type) { private static String toJson(Object value) { try { - return OBJECT_MAPPER.writeValueAsString(value); + return RESPONSE_OBJECT_MAPPER.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalArgumentException("Failed to serialize response", e); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java new file mode 100644 index 00000000000..1aaaf30da85 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloan/WorkingCapitalLoanDisbursementTestBuilder.java @@ -0,0 +1,108 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fineract.integrationtests.common.workingcapitalloan; + +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * Builds JSON request bodies for Working Capital Loan Disbursement API. + */ +public final class WorkingCapitalLoanDisbursementTestBuilder { + + private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; + private static final String DEFAULT_LOCALE = "en"; + + private WorkingCapitalLoanDisbursementTestBuilder() {} + + public static String buildDisburseJson(final LocalDate actualDisbursementDate, final BigDecimal transactionAmount, + final BigDecimal discountAmount, final String note, final Integer paymentTypeId, final String accountNumber, + final String checkNumber, final String routingCode, final String receiptNumber, final String bankNumber, + final String externalId) { + final JsonObject json = new JsonObject(); + json.addProperty("locale", DEFAULT_LOCALE); + json.addProperty("dateFormat", DEFAULT_DATE_FORMAT); + if (actualDisbursementDate != null) { + json.addProperty("actualDisbursementDate", actualDisbursementDate.format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + if (transactionAmount != null) { + json.addProperty("transactionAmount", transactionAmount); + } + if (discountAmount != null) { + json.addProperty("discountAmount", discountAmount); + } + if (note != null) { + json.addProperty("note", note); + } + if (paymentTypeId != null || accountNumber != null || checkNumber != null || routingCode != null || receiptNumber != null + || bankNumber != null) { + final JsonObject paymentDetails = new JsonObject(); + if (paymentTypeId != null) { + paymentDetails.addProperty("paymentTypeId", paymentTypeId); + } + if (accountNumber != null) { + paymentDetails.addProperty("accountNumber", accountNumber); + } + if (checkNumber != null) { + paymentDetails.addProperty("checkNumber", checkNumber); + } + if (routingCode != null) { + paymentDetails.addProperty("routingCode", routingCode); + } + if (receiptNumber != null) { + paymentDetails.addProperty("receiptNumber", receiptNumber); + } + if (bankNumber != null) { + paymentDetails.addProperty("bankNumber", bankNumber); + } + json.add("paymentDetails", paymentDetails); + } + if (externalId != null) { + json.addProperty("externalId", externalId); + } + return json.toString(); + } + + public static String buildDisburseJson(final LocalDate actualDisbursementDate, final BigDecimal transactionAmount, + final BigDecimal discountAmount, final String note, final Integer paymentTypeId, final String accountNumber, + final String checkNumber, final String routingCode, final String receiptNumber, final String bankNumber) { + return buildDisburseJson(actualDisbursementDate, transactionAmount, discountAmount, note, paymentTypeId, accountNumber, checkNumber, + routingCode, receiptNumber, bankNumber, null); + } + + public static String buildDisburseJson(final LocalDate actualDisbursementDate, final BigDecimal transactionAmount) { + return buildDisburseJson(actualDisbursementDate, transactionAmount, null, null, null, null, null, null, null, null); + } + + public static String buildUndoDisburseJson() { + return buildUndoDisburseJson(null); + } + + public static String buildUndoDisburseJson(final String note) { + final JsonObject json = new JsonObject(); + json.addProperty("locale", DEFAULT_LOCALE); + json.addProperty("dateFormat", DEFAULT_DATE_FORMAT); + if (note != null) { + json.addProperty("note", note); + } + return json.toString(); + } +} From 189e821aad6eb20bba4eb9aae8920f5085560685 Mon Sep 17 00:00:00 2001 From: MarianaDmytrivBinariks Date: Tue, 24 Mar 2026 17:33:38 +0200 Subject: [PATCH 2/3] FINERACT-2455: added e2e test scenarios for WC loan account disburse and undo disbursal --- .../WorkingCapitalLoanRequestFactory.java | 15 ++ .../test/helper/ErrorMessageHelper.java | 12 ++ ...rkingCapitalProductLoanAccountStepDef.java | 178 ++++++++++++++++++ .../WorkingCapitalProductLoanAccount.feature | 154 ++++++++++++++- 4 files changed, 349 insertions(+), 10 deletions(-) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java index fe3d997791f..73d7badd323 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java @@ -87,4 +87,19 @@ public PostWorkingCapitalLoansLoanIdRequest defaultWorkingCapitalLoanUndoApprova .dateFormat(DATE_FORMAT)// .locale(DEFAULT_LOCALE);// } + + public PostWorkingCapitalLoansLoanIdRequest defaultWorkingCapitalLoanDisburseRequest() { + return new PostWorkingCapitalLoansLoanIdRequest()// + .actualDisbursementDate(DATE_SUBMIT_STRING)// + .transactionAmount(DEFAULT_PRINCIPAL)// + .dateFormat(DATE_FORMAT)// + .locale(DEFAULT_LOCALE);// + } + + public PostWorkingCapitalLoansLoanIdRequest defaultWorkingCapitalLoanUndoDisburseRequest() { + return new PostWorkingCapitalLoansLoanIdRequest()// + .note("")// + .dateFormat(DATE_FORMAT)// + .locale(DEFAULT_LOCALE);// + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java index 5d34b506dc1..99196f4e005 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java @@ -1056,4 +1056,16 @@ public static String workingCapitalDelinquencyBucketNotFoundFailure(Long id) { public static String workingCapitalDelinquencyBucketDoesntExistFailure(Long id) { return String.format("Delinquency bucket with id `%d` does not exist.", id); } + + public static String disburseNotApprovedFailure(String status) { + return String.format("Disbursement is not allowed from current status %s", status); + } + + public static String disburseDateFailure(String errorMessageDescription) { + return String.format("Failed data validation due to: %s", errorMessageDescription); + } + + public static String undoDisbursalDisallowedFailure(String status) { + return String.format("Transition LOAN_DISBURSAL_UNDO is not allowed from status %s", status); + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalProductLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalProductLoanAccountStepDef.java index 0ebcbc05913..c6ddd7ec10c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalProductLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalProductLoanAccountStepDef.java @@ -20,11 +20,15 @@ import static org.apache.fineract.client.feign.util.FeignCalls.fail; import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import static org.apache.fineract.test.data.LoanStatus.ACTIVE; +import static org.apache.fineract.test.data.LoanStatus.APPROVED; +import static org.apache.fineract.test.data.LoanStatus.SUBMITTED_AND_PENDING_APPROVAL; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import io.cucumber.datatable.DataTable; +import io.cucumber.java.en.And; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; import java.math.BigDecimal; @@ -37,6 +41,7 @@ import org.apache.fineract.client.feign.FineractFeignClient; import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.models.DeleteWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.GetDisbursementDetail; import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; import org.apache.fineract.client.models.PostClientsResponse; import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdRequest; @@ -45,9 +50,11 @@ import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.test.data.LoanStatus; import org.apache.fineract.test.data.workingcapitalproduct.DefaultWorkingCapitalLoanProduct; import org.apache.fineract.test.data.workingcapitalproduct.WorkingCapitalLoanProductResolver; import org.apache.fineract.test.factory.WorkingCapitalLoanRequestFactory; +import org.apache.fineract.test.helper.ErrorMessageHelper; import org.apache.fineract.test.helper.Utils; import org.apache.fineract.test.messaging.event.EventCheckHelper; import org.apache.fineract.test.stepdef.AbstractStepDef; @@ -378,6 +385,23 @@ public void adminAttemptsToModifyNonExistentWorkingCapitalLoan() { log.info("Attempted to modify non-existent working capital loan ID {}", NON_EXISTENT_LOAN_ID); } + @Then("Modifying the working capital loan that is Disbursed in Active state results in an error") + public void modifyingDisbursedWithActiveStateLoanResultsInAnError() { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + + final PutWorkingCapitalLoansLoanIdRequest modifyRequest = workingCapitalLoanRequestFactory + .defaultModifyWorkingCapitalLoansRequest(); + + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoans().modifyWorkingCapitalLoanApplicationById(getCreatedLoanId(), modifyRequest, "")); + + assertThat(exception.getStatus()).as("HTTP status code should be 403").isEqualTo(403); + assertThat(exception.getMessage()).as("Should contain no parameters error") + .contains(String.format("Working Capital Loan with identifier %d cannot be modified in its current state.", loanId)); + log.info("Verified modification failed with disbursed Active status empty request"); + } + @Then("Working capital loan modification fails with a 404 not found error") public void workingCapitalLoanModificationFailsWith404() { final CallFailedRuntimeException exception = testContext().get(TestContextKey.LOAN_MODIFY_RESPONSE); @@ -465,6 +489,137 @@ public void verifyWorkingCapitalLoanUndoApprovalSuccess() { verifyStateTransitionSuccess(TestContextKey.LOAN_UNDO_APPROVAL_RESPONSE, "undo approval"); } + @When("Undo approval on the working capital loan results an error with the following data:") + public void undoApprovalWorkingCapitalLoan(final DataTable table) { + final PostWorkingCapitalLoansLoanIdRequest undoApprovalRequest = workingCapitalLoanRequestFactory + .defaultWorkingCapitalLoanUndoApprovalRequest(); + + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoans() + .stateTransitionWorkingCapitalLoanById(getCreatedLoanId(), "undoApproval", undoApprovalRequest)); + + verifyErrorResponse(exception, table); + log.info("Verified working capital loan undo approval failed with expected error"); + } + + @Then("Working Capital loan status will be {string}") + public void loanWCStatus(String statusExpected) { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + String resourceId = String.valueOf(loanId); + + GetWorkingCapitalLoansLoanIdResponse loanDetailsResponse = ok( + () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + + testContext().set(TestContextKey.LOAN_RESPONSE, loanDetailsResponse); + Long loanStatusActualValue = loanDetailsResponse.getStatus().getId(); + + LoanStatus loanStatusExpected = LoanStatus.valueOf(statusExpected); + Long loanStatusExpectedValue = loanStatusExpected.getValue().longValue(); + + assertThat(loanStatusActualValue) + .as(ErrorMessageHelper.wrongLoanStatus(resourceId, loanStatusActualValue.intValue(), loanStatusExpectedValue.intValue())) + .isEqualTo(loanStatusExpectedValue); + } + + @And("Admin successfully disburse the Working Capital loan on {string} with {string} EUR transaction amount") + public void disburseWCLoan(String actualDisbursementDate, String transactionAmount) { + PostWorkingCapitalLoansLoanIdRequest disburseRequest = workingCapitalLoanRequestFactory.defaultWorkingCapitalLoanDisburseRequest() + .actualDisbursementDate(actualDisbursementDate)// + .transactionAmount(new BigDecimal(transactionAmount)); + + executeStateTransition("disburse", disburseRequest, TestContextKey.LOAN_DISBURSE_RESPONSE, false); + verifyStateTransitionSuccess(TestContextKey.LOAN_DISBURSE_RESPONSE, "disbursement"); + checkChangesExpectedStatus(TestContextKey.LOAN_DISBURSE_RESPONSE, ACTIVE); + } + + @And("Admin successfully disburse the Working Capital loan by externalId on {string} with {string} EUR transaction amount") + public void disburseWCLoanByExternalId(String actualDisbursementDate, String transactionAmount) { + PostWorkingCapitalLoansLoanIdRequest disburseRequest = workingCapitalLoanRequestFactory.defaultWorkingCapitalLoanDisburseRequest() + .actualDisbursementDate(actualDisbursementDate)// + .transactionAmount(new BigDecimal(transactionAmount)); + + executeStateTransition("disburse", disburseRequest, TestContextKey.LOAN_DISBURSE_RESPONSE, true); + verifyStateTransitionSuccess(TestContextKey.LOAN_DISBURSE_RESPONSE, "disbursement"); + checkChangesExpectedStatus(TestContextKey.LOAN_DISBURSE_RESPONSE, ACTIVE); + } + + @Then("Verify Working Capital loan disbursement was successful on {string} with {string} EUR transaction amount") + public void checkDisbursementData(String actualDisbursementDate, String transactionAmount) { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + + GetWorkingCapitalLoansLoanIdResponse loanDetailsResponse = ok( + () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); + String getLoanStatus = loanDetailsResponse.getStatus().getValue(); + assertThat(getLoanStatus.toUpperCase()).isEqualTo(ACTIVE.name()); + + GetDisbursementDetail disbursementDetails = loanDetailsResponse.getDisbursementDetails().stream().findFirst() + .orElseThrow(() -> new RuntimeException("")); + String formattedDate = disbursementDetails.getActualDisbursementDate().format(FORMATTER); + assertThat(formattedDate).isEqualTo(actualDisbursementDate); + assertThat(disbursementDetails.getActualAmount().compareTo(new BigDecimal(transactionAmount))).isEqualTo(0); + } + + @Then("Admin successfully undo Working Capital disbursal") + public void undoDisbursalWCLoan() { + PostWorkingCapitalLoansLoanIdRequest undoDisbursalRequest = workingCapitalLoanRequestFactory + .defaultWorkingCapitalLoanUndoDisburseRequest(); + + executeStateTransition("undodisbursal", undoDisbursalRequest, TestContextKey.LOAN_UNDO_DISBURSE_RESPONSE, false); + verifyStateTransitionSuccess(TestContextKey.LOAN_UNDO_DISBURSE_RESPONSE, "undoDisbursement"); + checkChangesExpectedStatus(TestContextKey.LOAN_UNDO_DISBURSE_RESPONSE, APPROVED); + } + + @Then("Admin successfully undo Working Capital disbursal by externalId") + public void undoDisbursalWCLoanByexternalId() { + PostWorkingCapitalLoansLoanIdRequest undoDisbursalRequest = workingCapitalLoanRequestFactory + .defaultWorkingCapitalLoanUndoDisburseRequest(); + + executeStateTransition("undodisbursal", undoDisbursalRequest, TestContextKey.LOAN_UNDO_DISBURSE_RESPONSE, true); + verifyStateTransitionSuccess(TestContextKey.LOAN_UNDO_DISBURSE_RESPONSE, "undoDisbursement"); + checkChangesExpectedStatus(TestContextKey.LOAN_UNDO_DISBURSE_RESPONSE, APPROVED); + } + + @Then("Admin fails to disburse the Working Capital loan on {string} with {string} EUR transaction amount because of not approved") + public void disburseWCLoanFailureWithNotApproved(String actualDisbursementDate, String transactionAmount) { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + PostWorkingCapitalLoansLoanIdRequest disburseRequest = workingCapitalLoanRequestFactory.defaultWorkingCapitalLoanDisburseRequest() + .actualDisbursementDate(actualDisbursementDate).transactionAmount(new BigDecimal(transactionAmount)); + + CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoans().stateTransitionWorkingCapitalLoanById(loanId, + disburseRequest, Map.of("command", "disburse"))); + assertThat(exception.getStatus()).as(ErrorMessageHelper.dateFailureErrorCodeMsg()).isEqualTo(400); + assertThat(exception.getDeveloperMessage()) + .contains(ErrorMessageHelper.disburseNotApprovedFailure(SUBMITTED_AND_PENDING_APPROVAL.name())); + } + + @Then("Admin fails to disburse the Working Capital loan on {string} with {string} EUR transaction amount with invalid data outcomes with error message {string}") + public void disburseWCLoanFailureWithInvalidData(String actualDisbursementDate, String transactionAmount, + String errorMessageDescription) { + String errorMessage = ErrorMessageHelper.disburseDateFailure(errorMessageDescription); + disburseWCLoanFailure(actualDisbursementDate, transactionAmount, 400, errorMessage); + } + + @Then("Admin fails to disburse the Working Capital loan on {string} with {string} EUR transaction amount without mandatory data outcomes with error message {string}") + public void disburseWCLoanFailureWithoutMandatoryData(String actualDisbursementDate, String transactionAmount, String errorMessage) { + disburseWCLoanFailure(actualDisbursementDate, transactionAmount, 400, errorMessage); + } + + @Then("Admin fails to undo disbursal the Working Capital loan due to loan status {string}") + public void undoDisbursalWCLoanFailure(String actualLoanStatus) { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + + PostWorkingCapitalLoansLoanIdRequest undoDisbursalRequest = workingCapitalLoanRequestFactory + .defaultWorkingCapitalLoanUndoDisburseRequest(); + + CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoans().stateTransitionWorkingCapitalLoanById(loanId, + undoDisbursalRequest, Map.of("command", "undodisbursal"))); + assertThat(exception.getStatus()).as(ErrorMessageHelper.undoDisbursalDisallowedFailure(actualLoanStatus)).isEqualTo(400); + assertThat(exception.getDeveloperMessage()).contains(ErrorMessageHelper.undoDisbursalDisallowedFailure(actualLoanStatus)); + } + // ==================================== // Private Helper Methods // ==================================== @@ -668,4 +823,27 @@ private void verifyErrorResponse(final CallFailedRuntimeException exception, fin .isEqualTo(Integer.parseInt(expectedHttpCode)); assertThat(exception.getMessage()).as("Should contain error message").contains(expectedErrorMessage); } + + public void checkChangesExpectedStatus(String responseKey, LoanStatus expectedStatus) { + final PostWorkingCapitalLoansLoanIdResponse response = testContext().get(responseKey); + final Object changes = response.getChanges(); + assertThat(changes).as("Changes map").isNotNull().isInstanceOf(Map.class); + + @SuppressWarnings("unchecked") + final Map changesMap = (Map) changes; + assertThat(changesMap).as("Changes map should contain value '%s'", expectedStatus).containsValue(expectedStatus.name()); + } + + public void disburseWCLoanFailure(String actualDisbursementDate, String transactionAmount, int errorCode, String errorMessage) { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.getLoanId(); + PostWorkingCapitalLoansLoanIdRequest disburseRequest = workingCapitalLoanRequestFactory.defaultWorkingCapitalLoanDisburseRequest() + .actualDisbursementDate(actualDisbursementDate).transactionAmount(new BigDecimal(transactionAmount)); + + CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoans().stateTransitionWorkingCapitalLoanById(loanId, + disburseRequest, Map.of("command", "disburse"))); + assertThat(exception.getStatus()).as(errorMessage).isEqualTo(errorCode); + assertThat(exception.getDeveloperMessage()).contains(errorMessage); + } + } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalProductLoanAccount.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalProductLoanAccount.feature index c5a0e6dfab4..16a358d43eb 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalProductLoanAccount.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalProductLoanAccount.feature @@ -611,8 +611,8 @@ Feature: WorkingCapitalProduct | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 0.0 | -# TODO implement with disbursal testcases - @TestRailId:tempDisburse1 + + @TestRailId:C72367 Scenario: Approve Working Capital Loan account - UC8: Undo approval on already-disbursed loan When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -625,14 +625,13 @@ Feature: WorkingCapitalProduct | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | When Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" Then Working capital loan approval was successful -# When Admin disburses the working capital loan on "01 January 2026" -# Then Working capital loan disbursement was successful -# And Undo approval on the working capital loan results an error with the following data: -# | httpErrorCode | errorMessage | -# | 400 | msg | - - # TODO implement with disbursal testcases - @Skip @TestRailId:tempDisburse2 + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Verify Working Capital loan disbursement was successful on "01 January 2026" with "100" EUR transaction amount + Then Undo approval on the working capital loan results an error with the following data: + | httpErrorCode | errorMessage | + | 400 | Transition LOAN_APPROVAL_UNDO is not allowed from status ACTIVE | + + @Skip @TestRailId:C72368 Scenario: Create Working Capital Loan account - UC13: Attempt to modify loan in DISBURSED state (Negative) When Admin sets the business date to "01 January 2026" And Admin creates a client with random data @@ -643,3 +642,138 @@ Feature: WorkingCapitalProduct And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | + When Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Working capital loan approval was successful + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Verify Working Capital loan disbursement was successful on "01 January 2026" with "100" EUR transaction amount + Then Modifying the working capital loan that is Disbursed in Active state results in an error + + @TestRailId:C72371 + Scenario: Disburse Working Capital Loan account - UC1: Disburse loan that is not approved failure + 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 | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal| totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | + Then Admin fails to disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount because of not approved + + @TestRailId:C72372 + Scenario: Disburse Working Capital Loan account - UC2: Disburse loan successful use case + 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 | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | + Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + Then Verify Working Capital loan disbursement was successful on "01 January 2026" with "100" EUR transaction amount + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Active | 100.0 | 100.0 | 100.0 | 1.0 | 0.0 | + + @TestRailId:C72373 + Scenario Outline: Disburse Working Capital Loan account - UC3: Disburse loan with invalid data outcomes with an error + 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 | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 10 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-10 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | + When Admin sets the business date to "05 January 2026" + Then Admin successfully approves the working capital loan on "05 January 2026" with "100" amount and expected disbursement date on "10 January 2026" + Then Admin fails to disburse the Working Capital loan on "" with "" EUR transaction amount with invalid data outcomes with error message + + Examples: + | wcp_disburse_date | wcp_disburse_amount | wcp_error_message | + | 05 January 2027 | 100 | "cannot.be.a.future.date." | + | 05 January 2025 | 100 | "cannot.be.before.submitted.date." | + | 02 January 2026 | 100 | "cannot.be.before.approval.date." | + | 10 January 2026 | 1000 | "amount.cannot.exceed.approved.principal." | + + @TestRailId:C72374 + Scenario Outline: Disburse Working Capital Loan account - UC4: Disburse loan without mandatory data outcomes with an error + 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 | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 10 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-10 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | + When Admin sets the business date to "05 January 2026" + Then Admin successfully approves the working capital loan on "05 January 2026" with "100" amount and expected disbursement date on "10 January 2026" + Then Admin fails to disburse the Working Capital loan on with "" EUR transaction amount without mandatory data outcomes with error message + + Examples: + | wcp_disburse_date | wcp_disburse_amount | wcp_error_message | + | "" | 100 | "The parameter `actualDisbursementDate` is mandatory." | + | "05 January 2025" | 0 | "The parameter `transactionAmount` must be greater than 0." | + + @TestRailId:C72375 + Scenario: Undo Disbursal of Working Capital Loan account - UC5: Undo Disbursal loan of successful use case + 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 | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | + Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount + Then Admin successfully undo Working Capital disbursal + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 0.0 | + + @TestRailId:C72376 + Scenario: Undo Disbursal of Working Capital Loan account - UC6: Undo disbursal of loan that is submitted or approved is failed + 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 | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | + Then Admin fails to undo disbursal the Working Capital loan due to loan status "SUBMITTED_AND_PENDING_APPROVAL" + Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Admin fails to undo disbursal the Working Capital loan due to loan status "APPROVED" + + @TestRailId:C72377 + Scenario: Disburse Working Capital Loan account - UC7: Disburse loan and undo disbursal via externalId successful use case + 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 | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 100 | 100 | 1 | 0 | + Then Working capital loan creation was successful + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Submitted and pending approval | 100.0 | 0.0 | 100.0 | 1.0 | 0.0 | + Then Admin successfully approves the working capital loan on "01 January 2026" with "100" amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan by externalId on "01 January 2026" with "100" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + Then Verify Working Capital loan disbursement was successful on "01 January 2026" with "100" EUR transaction amount + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Active | 100.0 | 100.0 | 100.0 | 1.0 | 0.0 | + Then Admin successfully undo Working Capital disbursal by externalId + And Working capital loan account has the correct data: + | product.name | submittedOnDate | expectedDisbursementDate | status | principal | approvedPrincipal | totalPayment | periodPaymentRate | discount | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 100.0 | 100.0 | 100.0 | 1.0 | 0.0 | From 61cc19374abe5ed7dc82f324f8908bcfcf953876 Mon Sep 17 00:00:00 2001 From: Adam Saghy Date: Wed, 25 Mar 2026 15:52:48 +0100 Subject: [PATCH 3/3] FINERACT-2455: WC - Loan account Disbursement / Undo --- .../paymentdetail/data/PaymentDetailData.java | 2 + .../WorkingCapitalLoanTransactionData.java | 3 +- ...pitalLoanTransactionPaymentDetailData.java | 41 -------- .../domain/WorkingCapitalLoan.java | 2 +- .../domain/WorkingCapitalLoanTransaction.java | 9 +- ...rkingCapitalLoanTransactionAllocation.java | 6 +- ...ngCapitalLoanTransactionPaymentDetail.java | 59 ----------- .../WorkingCapitalLoanTransactionMapper.java | 16 ++- ...oanTransactionPaymentDetailRepository.java | 25 ----- ...rkingCapitalLoanTransactionRepository.java | 4 +- ...oanTransactionReadPlatformServiceImpl.java | 3 +- ...ngCapitalLoanWritePlatformServiceImpl.java | 97 ++++++++----------- ...alLoanProductWritePlatformServiceImpl.java | 3 + .../parts/0011_wc_loan_transaction.xml | 26 +---- ...rkingCapitalLoanTransactionMapperTest.java | 4 +- 15 files changed, 75 insertions(+), 225 deletions(-) delete mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java delete mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java delete mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymentdetail/data/PaymentDetailData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymentdetail/data/PaymentDetailData.java index 4ddd13a9241..c6041b9f1b5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/paymentdetail/data/PaymentDetailData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/paymentdetail/data/PaymentDetailData.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.paymentdetail.data; import java.io.Serializable; +import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -30,6 +31,7 @@ @Getter @EqualsAndHashCode @RequiredArgsConstructor +@Builder public class PaymentDetailData implements Serializable { private final Long id; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java index e52108ba42d..7edba61dc03 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionData.java @@ -28,6 +28,7 @@ import lombok.Setter; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; +import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData; @Getter @Setter @@ -46,7 +47,7 @@ public class WorkingCapitalLoanTransactionData implements Serializable { private ExternalId reversalExternalId; private LocalDate reversedOnDate; - private WorkingCapitalLoanTransactionPaymentDetailData paymentDetailData; + private PaymentDetailData paymentDetailData; // Portions from allocation (principal, fee, penalty). private BigDecimal principalPortion; private BigDecimal feeChargesPortion; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java deleted file mode 100644 index cc9c31e70d3..00000000000 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionPaymentDetailData.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.fineract.portfolio.workingcapitalloan.data; - -import java.io.Serializable; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class WorkingCapitalLoanTransactionPaymentDetailData implements Serializable { - - private Long id; - private String accountNumber; - private String checkNumber; - private String routingCode; - private String receiptNumber; - private String bankNumber; -} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java index 6330a88b2a2..2e49213ee46 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java @@ -163,7 +163,7 @@ public class WorkingCapitalLoan extends AbstractAuditableWithUTCDateTimeCustom disbursementDetails = new ArrayList<>(); - @OrderBy(value = "dateOf, createdDate, id") + @OrderBy(value = "transactionDate, createdDate, id") @OneToMany(cascade = CascadeType.ALL, mappedBy = "wcLoan", orphanRemoval = true, fetch = FetchType.LAZY) private List transactions = new ArrayList<>(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java index 73d0c3231b1..938b33b03c1 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransaction.java @@ -38,6 +38,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionTypeConverter; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; @Entity @Table(name = "m_wc_loan_transaction", uniqueConstraints = { @@ -54,7 +55,7 @@ public class WorkingCapitalLoanTransaction extends AbstractAuditableWithUTCDateT private LoanTransactionType transactionType; @Column(name = "transaction_date", nullable = false) - private LocalDate dateOf; + private LocalDate transactionDate; @Column(name = "submitted_on_date", nullable = false) private LocalDate submittedOnDate; @@ -64,7 +65,7 @@ public class WorkingCapitalLoanTransaction extends AbstractAuditableWithUTCDateT @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "payment_detail_id") - private WorkingCapitalLoanTransactionPaymentDetail paymentDetail; + private PaymentDetail paymentDetail; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "classification_cv_id") @@ -100,11 +101,11 @@ public LoanTransactionType getTypeOf() { } public static WorkingCapitalLoanTransaction disbursement(final WorkingCapitalLoan loan, final BigDecimal amount, - final WorkingCapitalLoanTransactionPaymentDetail paymentDetail, final LocalDate disbursementDate, final ExternalId externalId) { + final PaymentDetail paymentDetail, final LocalDate disbursementDate, final ExternalId externalId) { final WorkingCapitalLoanTransaction txn = new WorkingCapitalLoanTransaction(); txn.wcLoan = loan; txn.transactionType = LoanTransactionType.DISBURSEMENT; - txn.dateOf = disbursementDate; + txn.transactionDate = disbursementDate; txn.submittedOnDate = disbursementDate; txn.transactionAmount = amount; txn.paymentDetail = paymentDetail; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java index de117923a64..782aed9bb57 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionAllocation.java @@ -41,15 +41,15 @@ public class WorkingCapitalLoanTransactionAllocation extends AbstractAuditableWi @JoinColumn(name = "wc_loan_transaction_id", nullable = false, unique = true) private WorkingCapitalLoanTransaction wcLoanTransaction; - @Column(name = "principal_portion_derived", scale = 6, precision = 19) + @Column(name = "principal_portion", scale = 6, precision = 19) @Setter private BigDecimal principalPortion; - @Column(name = "fee_charges_portion_derived", scale = 6, precision = 19) + @Column(name = "fee_charges_portion", scale = 6, precision = 19) @Setter private BigDecimal feeChargesPortion; - @Column(name = "penalty_charges_portion_derived", scale = 6, precision = 19) + @Column(name = "penalty_charges_portion", scale = 6, precision = 19) @Setter private BigDecimal penaltyChargesPortion; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java deleted file mode 100644 index 99a66ab5d2e..00000000000 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanTransactionPaymentDetail.java +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.fineract.portfolio.workingcapitalloan.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; -import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; - -@Entity -@Table(name = "m_wc_loan_transaction_payment_detail") -@Getter -public class WorkingCapitalLoanTransactionPaymentDetail extends AbstractPersistableCustom { - - @Column(name = "account_number", length = 50) - private String accountNumber; - - @Column(name = "check_number", length = 50) - private String checkNumber; - - @Column(name = "routing_code", length = 50) - private String routingCode; - - @Column(name = "receipt_number", length = 50) - private String receiptNumber; - - @Column(name = "bank_number", length = 50) - private String bankNumber; - - protected WorkingCapitalLoanTransactionPaymentDetail() {} - - public static WorkingCapitalLoanTransactionPaymentDetail of(final String accountNumber, final String checkNumber, - final String routingCode, final String receiptNumber, final String bankNumber) { - final WorkingCapitalLoanTransactionPaymentDetail d = new WorkingCapitalLoanTransactionPaymentDetail(); - d.accountNumber = accountNumber; - d.checkNumber = checkNumber; - d.routingCode = routingCode; - d.receiptNumber = receiptNumber; - d.bankNumber = bankNumber; - return d; - } -} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java index d5017da9bd9..8378af3f771 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapper.java @@ -22,10 +22,10 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; +import org.apache.fineract.portfolio.paymentdetail.data.PaymentDetailData; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; -import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionPaymentDetailData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; -import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; @@ -35,7 +35,7 @@ public interface WorkingCapitalLoanTransactionMapper { @Mapping(target = "type", source = "transactionType", qualifiedByName = "loanTransactionTypeToEnumData") @Mapping(target = "paymentDetailData", source = "paymentDetail", qualifiedByName = "paymentDetailToData") - @Mapping(target = "transactionDate", source = "dateOf") + @Mapping(target = "transactionDate", source = "transactionDate") @Mapping(target = "principalPortion", source = "allocation.principalPortion") @Mapping(target = "feeChargesPortion", source = "allocation.feeChargesPortion") @Mapping(target = "penaltyChargesPortion", source = "allocation.penaltyChargesPortion") @@ -47,14 +47,12 @@ default LoanTransactionEnumData loanTransactionTypeToEnumData(final LoanTransact } @Named("paymentDetailToData") - default WorkingCapitalLoanTransactionPaymentDetailData paymentDetailToData( - final WorkingCapitalLoanTransactionPaymentDetail paymentDetail) { + default PaymentDetailData paymentDetailToData(final PaymentDetail paymentDetail) { if (paymentDetail == null) { return null; } - return WorkingCapitalLoanTransactionPaymentDetailData.builder().id(paymentDetail.getId()) - .accountNumber(paymentDetail.getAccountNumber()).checkNumber(paymentDetail.getCheckNumber()) - .routingCode(paymentDetail.getRoutingCode()).receiptNumber(paymentDetail.getReceiptNumber()) - .bankNumber(paymentDetail.getBankNumber()).build(); + return PaymentDetailData.builder().id(paymentDetail.getId()).accountNumber(paymentDetail.getAccountNumber()) + .checkNumber(paymentDetail.getCheckNumber()).routingCode(paymentDetail.getRoutingCode()) + .receiptNumber(paymentDetail.getReceiptNumber()).bankNumber(paymentDetail.getBankNumber()).build(); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java deleted file mode 100644 index 0e37197bdb3..00000000000 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionPaymentDetailRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.fineract.portfolio.workingcapitalloan.repository; - -import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface WorkingCapitalLoanTransactionPaymentDetailRepository - extends JpaRepository {} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java index 7429dd17246..59e4c0cb9a3 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/repository/WorkingCapitalLoanTransactionRepository.java @@ -28,9 +28,9 @@ public interface WorkingCapitalLoanTransactionRepository extends JpaRepository { - List findByWcLoan_IdOrderByDateOfAscIdAsc(Long wcLoanId); + List findByWcLoan_IdOrderByTransactionDateAscIdAsc(Long wcLoanId); - Page findByWcLoan_IdOrderByDateOfAscIdAsc(Long wcLoanId, Pageable pageable); + Page findByWcLoan_IdOrderByTransactionDateAscIdAsc(Long wcLoanId, Pageable pageable); Optional findByIdAndWcLoan_Id(Long id, Long wcLoanId); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java index 9f1c04f2ddf..3dee5cf6953 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java @@ -69,7 +69,8 @@ public WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(fin @Override public Page retrieveTransactions(final Long loanId, final Pageable pageable) { ensureLoanExists(loanId); - final Page page = this.transactionRepository.findByWcLoan_IdOrderByDateOfAscIdAsc(loanId, pageable); + final Page page = this.transactionRepository.findByWcLoan_IdOrderByTransactionDateAscIdAsc(loanId, + pageable); final List content = page.getContent().stream().map(this.transactionMapper::toData).toList(); return new PageImpl<>(content, page.getPageable(), page.getTotalElements()); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index 4c51d0fe1be..4363792ce18 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -40,6 +40,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; +import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; @@ -49,13 +51,11 @@ import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanNote; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; -import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionPaymentDetail; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanBalanceRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanNoteRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionAllocationRepository; -import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionPaymentDetailRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanDataValidator; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; @@ -78,7 +78,7 @@ public class WorkingCapitalLoanWritePlatformServiceImpl implements WorkingCapita private final ExternalIdFactory externalIdFactory; private final WorkingCapitalLoanTransactionRepository transactionRepository; private final WorkingCapitalLoanTransactionAllocationRepository allocationRepository; - private final WorkingCapitalLoanTransactionPaymentDetailRepository paymentDetailRepository; + private final PaymentDetailWritePlatformService paymentDetailService; private final WorkingCapitalLoanBalanceRepository balanceRepository; private final WorkingCapitalLoanAmortizationScheduleWriteService amortizationScheduleWriteService; @@ -136,9 +136,15 @@ public CommandProcessingResult approveApplication(final Long loanId, final JsonC log.debug("Working capital loan {} approved by user {}", loanId, currentUser.getId()); - return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId) - .withEntityExternalId(loan.getExternalId()).withOfficeId(loan.getOfficeId()).withClientId(loan.getClientId()) - .withLoanId(loanId).with(changes).build(); + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loanId) // + .with(changes) // + .build(); } @Override @@ -171,9 +177,15 @@ public CommandProcessingResult undoApplicationApproval(final Long loanId, final log.debug("Working capital loan {} approval undone", loanId); - return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId) - .withEntityExternalId(loan.getExternalId()).withOfficeId(loan.getOfficeId()).withClientId(loan.getClientId()) - .withLoanId(loanId).with(changes).build(); + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loanId) // + .with(changes) // + .build(); } @Override @@ -201,9 +213,15 @@ public CommandProcessingResult rejectApplication(final Long loanId, final JsonCo log.debug("Working capital loan {} rejected by user {}", loanId, currentUser.getId()); - return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId) - .withEntityExternalId(loan.getExternalId()).withOfficeId(loan.getOfficeId()).withClientId(loan.getClientId()) - .withLoanId(loanId).with(changes).build(); + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withLoanId(loanId) // + .with(changes) // + .build(); } @Transactional @@ -229,7 +247,7 @@ public CommandProcessingResult disburseLoan(final Long loanId, final JsonCommand final Map changes = new LinkedHashMap<>(); changes.put(WorkingCapitalLoanConstants.actualDisbursementDateParamName, actualDisbursementDate); changes.put(WorkingCapitalLoanConstants.transactionAmountParamName, transactionAmount); - final WorkingCapitalLoanTransactionPaymentDetail paymentDetail = createAndPersistPaymentDetailFromCommand(command, changes); + final PaymentDetail paymentDetail = createAndPersistPaymentDetailFromCommand(command, changes); this.stateMachine.transition(WorkingCapitalLoanEvent.LOAN_DISBURSED, loan); @@ -325,53 +343,22 @@ public CommandProcessingResult undoDisbursal(final Long loanId, final JsonComman log.debug("Working capital loan {} disbursal undone", loanId); - return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(loanId) - .withEntityExternalId(loan.getExternalId()).withLoanId(loanId).with(changes).build(); + return new CommandProcessingResultBuilder() // + .withCommandId(command.commandId()) // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .withLoanId(loanId) // + .with(changes) // + .build(); } - private WorkingCapitalLoanTransactionPaymentDetail createAndPersistPaymentDetailFromCommand(final JsonCommand command, - final Map changes) { + private PaymentDetail createAndPersistPaymentDetailFromCommand(final JsonCommand command, final Map changes) { final JsonElement paymentDetailsElement = command.jsonElement(WorkingCapitalLoanConstants.paymentDetailsParamName); if (paymentDetailsElement != null && paymentDetailsElement.isJsonObject()) { final JsonCommand paymentDetailsCommand = JsonCommand.fromExistingCommand(command, paymentDetailsElement); - return createAndPersistWclPaymentDetail(paymentDetailsCommand, changes); - } - return createAndPersistWclPaymentDetail(command, changes); - } - - private WorkingCapitalLoanTransactionPaymentDetail createAndPersistWclPaymentDetail(final JsonCommand paymentDetailsCommand, - final Map changes) { - final String accountNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.accountNumberParamName); - final String checkNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.checkNumberParamName); - final String routingCode = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.routingCodeParamName); - final String receiptNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.receiptNumberParamName); - final String bankNumber = paymentDetailsCommand.stringValueOfParameterNamed(WorkingCapitalLoanConstants.bankNumberParamName); - - final boolean hasAny = StringUtils.isNotBlank(accountNumber) || StringUtils.isNotBlank(checkNumber) - || StringUtils.isNotBlank(routingCode) || StringUtils.isNotBlank(receiptNumber) || StringUtils.isNotBlank(bankNumber); - if (!hasAny) { - return null; - } - - if (StringUtils.isNotBlank(accountNumber)) { - changes.put(WorkingCapitalLoanConstants.accountNumberParamName, accountNumber); + return paymentDetailService.createPaymentDetail(paymentDetailsCommand, changes); } - if (StringUtils.isNotBlank(checkNumber)) { - changes.put(WorkingCapitalLoanConstants.checkNumberParamName, checkNumber); - } - if (StringUtils.isNotBlank(routingCode)) { - changes.put(WorkingCapitalLoanConstants.routingCodeParamName, routingCode); - } - if (StringUtils.isNotBlank(receiptNumber)) { - changes.put(WorkingCapitalLoanConstants.receiptNumberParamName, receiptNumber); - } - if (StringUtils.isNotBlank(bankNumber)) { - changes.put(WorkingCapitalLoanConstants.bankNumberParamName, bankNumber); - } - - final WorkingCapitalLoanTransactionPaymentDetail detail = WorkingCapitalLoanTransactionPaymentDetail.of(accountNumber, checkNumber, - routingCode, receiptNumber, bankNumber); - return this.paymentDetailRepository.saveAndFlush(detail); + return paymentDetailService.createPaymentDetail(command, changes); } private void updateBalanceOnDisburse(final WorkingCapitalLoan loan, final BigDecimal disbursedAmount) { @@ -385,7 +372,7 @@ private void updateBalanceOnDisburse(final WorkingCapitalLoan loan, final BigDec private void reverseDisbursementTransactionsAndResetBalance(final WorkingCapitalLoan loan) { final List transactions = this.transactionRepository - .findByWcLoan_IdOrderByDateOfAscIdAsc(loan.getId()); + .findByWcLoan_IdOrderByTransactionDateAscIdAsc(loan.getId()); for (WorkingCapitalLoanTransaction txn : transactions) { if (txn.getTypeOf() == LoanTransactionType.DISBURSEMENT && !txn.isReversed()) { txn.setReversed(true); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java index 3aac1730967..54dc18ebca0 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java @@ -92,6 +92,7 @@ public CommandProcessingResult createWorkingCapitalLoanProduct(final JsonCommand return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(product.getId()) // + .withEntityExternalId(product.getExternalId()) // .build(); } @@ -138,6 +139,7 @@ public CommandProcessingResult updateWorkingCapitalLoanProduct(final Long produc return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(productId) // + .withEntityExternalId(product.getExternalId()) // .with(changes) // .build(); } @@ -156,6 +158,7 @@ public CommandProcessingResult deleteWorkingCapitalLoanProduct(final Long produc return new CommandProcessingResultBuilder() // .withEntityId(productId) // + .withEntityExternalId(product.getExternalId()) // .build(); } diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0011_wc_loan_transaction.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0011_wc_loan_transaction.xml index d8568d79ebd..ec62b566bf3 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0011_wc_loan_transaction.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0011_wc_loan_transaction.xml @@ -67,24 +67,6 @@ - - - - - - - - - - - - - - - - - - @@ -127,7 +109,7 @@ + constraintName="FK_m_wc_loan_transaction_payment_detail" referencedTableName="m_payment_detail" referencedColumnNames="id"/> @@ -206,9 +188,9 @@ - - - + + + diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java index 3b16be53c08..e8286c21421 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/WorkingCapitalLoanTransactionMapperTest.java @@ -53,7 +53,7 @@ void toData_mapsAllFieldsIncludingAllocationPortions() { final BigDecimal amount = BigDecimal.valueOf(10000); when(transaction.getId()).thenReturn(1L); when(transaction.getTransactionType()).thenReturn(LoanTransactionType.DISBURSEMENT); - when(transaction.getDateOf()).thenReturn(txnDate); + when(transaction.getTransactionDate()).thenReturn(txnDate); when(transaction.getSubmittedOnDate()).thenReturn(txnDate); when(transaction.getTransactionAmount()).thenReturn(amount); when(transaction.getExternalId()).thenReturn(new ExternalId("ext-1")); @@ -85,7 +85,7 @@ void toData_mapsAllFieldsIncludingAllocationPortions() { void toData_whenAllocationNull_setsPortionsToNull() { when(transaction.getId()).thenReturn(2L); when(transaction.getTransactionType()).thenReturn(LoanTransactionType.DISBURSEMENT); - when(transaction.getDateOf()).thenReturn(LocalDate.of(2024, 2, 1)); + when(transaction.getTransactionDate()).thenReturn(LocalDate.of(2024, 2, 1)); when(transaction.getSubmittedOnDate()).thenReturn(LocalDate.of(2024, 2, 1)); when(transaction.getTransactionAmount()).thenReturn(BigDecimal.valueOf(5000)); when(transaction.getExternalId()).thenReturn(null);