diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java index 059ac484514..caa2cd26564 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingHelper.java @@ -167,8 +167,18 @@ public void savePaymentChannelToFundSourceMappings(final JsonCommand command, fi } for (int i = 0; i < paymentChannelMappingArray.size(); i++) { final JsonObject jsonObject = paymentChannelMappingArray.get(i).getAsJsonObject(); - final Long paymentTypeId = jsonObject.get(LoanProductAccountingParams.PAYMENT_TYPE.getValue()).getAsLong(); - final Long paymentSpecificFundAccountId = jsonObject.get(LoanProductAccountingParams.FUND_SOURCE.getValue()).getAsLong(); + JsonElement jsonPaymentTypeId = jsonObject.get(LoanProductAccountingParams.PAYMENT_TYPE.getValue()); + JsonElement jsonElementFoundId = jsonObject.get(LoanProductAccountingParams.FUND_SOURCE.getValue()); + if (jsonPaymentTypeId == null) { + throw new PlatformApiDataValidationException("payment.type.id.is.mandatory", "field: paymentTypeId is mandatory", + LoanProductAccountingParams.PAYMENT_TYPE.getValue()); + } + if (jsonElementFoundId == null) { + throw new PlatformApiDataValidationException("fund.source.account.id.is.mandatory", + "field: fundSourceAccountId is mandatory", LoanProductAccountingParams.FUND_SOURCE.getValue()); + } + final Long paymentTypeId = jsonPaymentTypeId.getAsLong(); + final Long paymentSpecificFundAccountId = jsonElementFoundId.getAsLong(); savePaymentChannelToFundSourceMapping(productId, paymentTypeId, paymentSpecificFundAccountId, portfolioProductType); } } @@ -200,7 +210,18 @@ public void saveChargesToGLAccountMappings(final JsonCommand command, final Json } for (int i = 0; i < chargeToIncomeAccountMappingArray.size(); i++) { final JsonObject jsonObject = chargeToIncomeAccountMappingArray.get(i).getAsJsonObject(); - final Long chargeId = jsonObject.get(LoanProductAccountingParams.CHARGE_ID.getValue()).getAsLong(); + JsonElement chargeIdJson = jsonObject.get(LoanProductAccountingParams.CHARGE_ID.getValue()); + JsonElement incomeAccountJson = jsonObject.get(LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue()); + if (chargeIdJson == null) { + throw new PlatformApiDataValidationException("charge.id.is.mandatory", "chargeId is mandatory", + LoanProductAccountingParams.CHARGE_ID.getValue()); + } + if (incomeAccountJson == null) { + throw new PlatformApiDataValidationException("income.account.id.is.mandatory", "incomeAccountId is mandatory", + LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue()); + + } + final Long chargeId = chargeIdJson.getAsLong(); final Long incomeAccountId = jsonObject.get(LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue()).getAsLong(); saveChargeToFundSourceMapping(productId, chargeId, incomeAccountId, portfolioProductType, isPenalty); } @@ -222,8 +243,20 @@ public void saveReasonToGLAccountMappings(final JsonCommand command, final JsonE for (int i = 0; i < reasonToExpenseAccountMappingArray.size(); i++) { final JsonObject jsonObject = reasonToExpenseAccountMappingArray.get(i).getAsJsonObject(); - final Long reasonId = jsonObject.get(reasonCodeValueIdParam.getValue()).getAsLong(); - final Long expenseAccountId = jsonObject.get(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()).getAsLong(); + + JsonElement reasonIdJson = jsonObject.get(reasonCodeValueIdParam.getValue()); + JsonElement expenseAccountJson = jsonObject.get(LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()); + if (reasonIdJson == null) { + throw new PlatformApiDataValidationException(reasonCodeValueIdParam.getValue() + ".is.mandatory", + reasonCodeValueIdParam.getValue() + " is mandatory", reasonCodeValueIdParam.getValue()); + } + if (expenseAccountJson == null) { + throw new PlatformApiDataValidationException("expense.gl.account.id.is.mandatory", "expenseGlAccountId is mandatory", + LoanProductAccountingParams.EXPENSE_GL_ACCOUNT_ID.getValue()); + } + + final Long reasonId = reasonIdJson.getAsLong(); + final Long expenseAccountId = expenseAccountJson.getAsLong(); saveReasonToExpenseMapping(productId, reasonId, expenseAccountId, portfolioProductType, cashAccountsForLoan); } diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/WorkingCapitalLoanProductAdvancedAccountingReadHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/WorkingCapitalLoanProductAdvancedAccountingReadHelper.java new file mode 100644 index 00000000000..de9f891e197 --- /dev/null +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/WorkingCapitalLoanProductAdvancedAccountingReadHelper.java @@ -0,0 +1,104 @@ +/** + * 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.accounting.producttoaccountmapping.service; + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.data.GLAccountData; +import org.apache.fineract.accounting.producttoaccountmapping.data.AdvancedMappingToExpenseAccountData; +import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeToGLAccountMapper; +import org.apache.fineract.accounting.producttoaccountmapping.data.PaymentTypeToGLAccountMapper; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; +import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; +import org.apache.fineract.infrastructure.codes.data.CodeValueData; +import org.apache.fineract.infrastructure.codes.mapper.CodeValueMapper; +import org.apache.fineract.portfolio.PortfolioProductType; +import org.apache.fineract.portfolio.charge.data.ChargeData; +import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WorkingCapitalLoanProductAdvancedAccountingReadHelper { + + private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; + private final CodeValueMapper codeValueMapper; + + public List fetchPaymentTypeToFundSourceMappings(final Long wcLoanProductId) { + final List mappings = productToGLAccountMappingRepository.findAllPaymentTypeMappings(wcLoanProductId, + PortfolioProductType.WORKING_CAPITAL_LOAN.getValue()); + final List result = new ArrayList<>(); + for (final ProductToGLAccountMapping mapping : mappings) { + final PaymentTypeData paymentTypeData = PaymentTypeData.builder().id(mapping.getPaymentType().getId()) + .name(mapping.getPaymentType().getName()).build(); + final GLAccountData gLAccountData = new GLAccountData().setId(mapping.getGlAccount().getId()) + .setName(mapping.getGlAccount().getName()).setGlCode(mapping.getGlAccount().getGlCode()); + result.add(new PaymentTypeToGLAccountMapper().setPaymentType(paymentTypeData).setFundSourceAccount(gLAccountData)); + } + return result.isEmpty() ? null : result; + } + + public List fetchFeeToIncomeMappings(final Long wcLoanProductId) { + return fetchChargeToIncomeMappings(wcLoanProductId, false); + } + + public List fetchPenaltyToIncomeMappings(final Long wcLoanProductId) { + return fetchChargeToIncomeMappings(wcLoanProductId, true); + } + + public List fetchChargeOffReasonMappings(final Long wcLoanProductId) { + return fetchReasonMappings(productToGLAccountMappingRepository.findAllChargeOffReasonsMappings(wcLoanProductId, + PortfolioProductType.WORKING_CAPITAL_LOAN.getValue())); + } + + public List fetchWriteOffReasonMappings(final Long wcLoanProductId) { + return fetchReasonMappings(productToGLAccountMappingRepository.findAllWriteOffReasonsMappings(wcLoanProductId, + PortfolioProductType.WORKING_CAPITAL_LOAN.getValue())); + } + + private List fetchChargeToIncomeMappings(final Long wcLoanProductId, final boolean penalty) { + final List mappings = penalty + ? productToGLAccountMappingRepository.findAllPenaltyMappings(wcLoanProductId, + PortfolioProductType.WORKING_CAPITAL_LOAN.getValue()) + : productToGLAccountMappingRepository.findAllFeeMappings(wcLoanProductId, + PortfolioProductType.WORKING_CAPITAL_LOAN.getValue()); + final List result = new ArrayList<>(); + for (final ProductToGLAccountMapping mapping : mappings) { + final GLAccountData gLAccountData = new GLAccountData().setId(mapping.getGlAccount().getId()) + .setName(mapping.getGlAccount().getName()).setGlCode(mapping.getGlAccount().getGlCode()); + final ChargeData chargeData = ChargeData.builder().id(mapping.getCharge().getId()).name(mapping.getCharge().getName()) + .penalty(mapping.getCharge().isPenalty()).build(); + result.add(new ChargeToGLAccountMapper().setCharge(chargeData).setIncomeAccount(gLAccountData)); + } + return result.isEmpty() ? null : result; + } + + private List fetchReasonMappings(final List mappings) { + final List result = new ArrayList<>(); + for (final ProductToGLAccountMapping mapping : mappings) { + final GLAccountData expenseAccount = new GLAccountData().setId(mapping.getGlAccount().getId()) + .setName(mapping.getGlAccount().getName()).setGlCode(mapping.getGlAccount().getGlCode()); + final CodeValueData codeValue = mapping.getChargeOffReason() != null ? codeValueMapper.map(mapping.getChargeOffReason()) + : codeValueMapper.map(mapping.getWriteOffReason()); + result.add(new AdvancedMappingToExpenseAccountData().setReasonCodeValue(codeValue).setExpenseAccount(expenseAccount)); + } + return result.isEmpty() ? null : result; + } +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanProductAdvancedAccountingTestHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanProductAdvancedAccountingTestHelper.java new file mode 100644 index 00000000000..ca91660bcb9 --- /dev/null +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanProductAdvancedAccountingTestHelper.java @@ -0,0 +1,267 @@ +/** + * 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.test.helper; + +import static org.apache.fineract.client.feign.util.FeignCalls.executeVoid; +import static org.apache.fineract.client.feign.util.FeignCalls.ok; +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.models.ChargeRequest; +import org.apache.fineract.client.models.GetCodeValuesDataResponse; +import org.apache.fineract.client.models.GetGLAccountsResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoanProductsProductIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoanProductsTemplateResponse; +import org.apache.fineract.client.models.PostChargesResponse; +import org.apache.fineract.client.models.PostGLAccountsRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoanProductsProductIdRequest; +import org.apache.fineract.client.models.WorkingCapitalLoanPaymentChannelToFundSourceMappings; +import org.apache.fineract.client.models.WorkingCapitalLoanProductChargeToGLAccountMapper; +import org.apache.fineract.client.models.WorkingCapitalPostChargeOffReasonToExpenseAccountMappings; +import org.apache.fineract.client.models.WorkingCapitalPostWriteOffReasonToExpenseAccountMappings; +import org.apache.fineract.test.data.ChargeCalculationType; +import org.apache.fineract.test.data.ChargePaymentMode; +import org.apache.fineract.test.data.ChargeTimeType; +import org.apache.fineract.test.data.GLAType; +import org.apache.fineract.test.data.GLAUsage; +import org.apache.fineract.test.data.paymenttype.DefaultPaymentType; +import org.apache.fineract.test.data.paymenttype.PaymentTypeResolver; +import org.apache.fineract.test.factory.GLAccountRequestFactory; + +public final class WorkingCapitalLoanProductAdvancedAccountingTestHelper { + + private WorkingCapitalLoanProductAdvancedAccountingTestHelper() {} + + public static void assertTemplateHasOptions(final GetWorkingCapitalLoanProductsTemplateResponse template) { + final JsonNode root = toTree(new ObjectMapper(), template); + assertThat(root.path("paymentTypeOptions").isArray()).as("paymentTypeOptions must be present in template").isTrue(); + assertThat(root.path("chargeOffReasonOptions").isArray()).as("chargeOffReasonOptions must be present in template").isTrue(); + assertThat(root.path("writeOffReasonOptions").isArray()).as("writeOffReasonOptions must be present in template").isTrue(); + assertThat(root.path("paymentTypeOptions").isEmpty()).as("paymentTypeOptions must not be empty").isFalse(); + assertThat(root.path("chargeOffReasonOptions").isEmpty()).as("chargeOffReasonOptions must not be empty").isFalse(); + assertThat(root.path("writeOffReasonOptions").isEmpty()).as("writeOffReasonOptions must not be empty").isFalse(); + } + + public static void assertProductHasAdvancedMappings(final ObjectMapper objectMapper, + final GetWorkingCapitalLoanProductsProductIdResponse product) { + final JsonNode root = toTree(objectMapper, product); + assertNonEmptyArray(root, "paymentChannelToFundSourceMappings"); + assertNonEmptyArray(root, "feeToIncomeAccountMappings"); + assertNonEmptyArray(root, "penaltyToIncomeAccountMappings"); + assertNonEmptyArray(root, "chargeOffReasonToExpenseAccountMappings"); + assertNonEmptyArray(root, "writeOffReasonsToExpenseMappings"); + } + + public static AdvancedAccountingExpectation prepareAdvancedMappings(final PostWorkingCapitalLoanProductsRequest request, + final PaymentTypeResolver paymentTypeResolver, final FineractFeignClient fineractFeignClient) { + return prepareAdvancedMappingsInternal(request::setFundSourceAccountId, request::setIncomeFromFeeAccountId, + request::setIncomeFromPenaltyAccountId, request::setWriteOffAccountId, request::setPaymentChannelToFundSourceMappings, + request::setFeeToIncomeAccountMappings, request::setPenaltyToIncomeAccountMappings, + request::setChargeOffReasonToExpenseAccountMappings, request::setWriteOffReasonsToExpenseMappings, paymentTypeResolver, + fineractFeignClient); + } + + public static AdvancedAccountingExpectation prepareAdvancedMappings(final PutWorkingCapitalLoanProductsProductIdRequest request, + final PaymentTypeResolver paymentTypeResolver, final FineractFeignClient fineractFeignClient) { + return prepareAdvancedMappingsInternal(request::setFundSourceAccountId, request::setIncomeFromFeeAccountId, + request::setIncomeFromPenaltyAccountId, request::setWriteOffAccountId, request::setPaymentChannelToFundSourceMappings, + request::setFeeToIncomeAccountMappings, request::setPenaltyToIncomeAccountMappings, + request::setChargeOffReasonToExpenseAccountMappings, request::setWriteOffReasonsToExpenseMappings, paymentTypeResolver, + fineractFeignClient); + } + + private static AdvancedAccountingExpectation prepareAdvancedMappingsInternal(final Consumer setFundSourceAccountId, + final Consumer setFeeIncomeAccountId, final Consumer setPenaltyIncomeAccountId, + final Consumer setWriteOffAccountId, + final Consumer> setPaymentChannelMappings, + final Consumer> setFeeMappings, + final Consumer> setPenaltyMappings, + final Consumer> setChargeOffMappings, + final Consumer> setWriteOffMappings, + final PaymentTypeResolver paymentTypeResolver, final FineractFeignClient fineractFeignClient) { + final Long fundSourceAccountId = resolveOrCreateGLAccount(fineractFeignClient, "WC E2E Mapping Fund Source", "WCE2EFS1", + GLAType.ASSET.value); + final Long feeIncomeAccountId = resolveOrCreateGLAccount(fineractFeignClient, "WC E2E Mapping Fee Income", "WCE2EFI1", + GLAType.INCOME.value); + final Long penaltyIncomeAccountId = resolveOrCreateGLAccount(fineractFeignClient, "WC E2E Mapping Penalty Income", "WCE2EPI1", + GLAType.INCOME.value); + final Long writeOffExpenseAccountId = resolveOrCreateGLAccount(fineractFeignClient, "WC E2E Mapping Write Off Expense", "WCE2EWE1", + GLAType.EXPENSE.value); + + setFundSourceAccountId.accept(fundSourceAccountId); + setFeeIncomeAccountId.accept(feeIncomeAccountId); + setPenaltyIncomeAccountId.accept(penaltyIncomeAccountId); + setWriteOffAccountId.accept(writeOffExpenseAccountId); + + final Long paymentTypeId = paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER); + final Long feeChargeId = createChargeForAdvancedMappings(fineractFeignClient, false, 2.0); + final Long penaltyChargeId = createChargeForAdvancedMappings(fineractFeignClient, true, 1.0); + final Long chargeOffReasonId = resolveFirstCodeValueId(fineractFeignClient, "ChargeOffReasons"); + final Long writeOffReasonId = resolveFirstCodeValueId(fineractFeignClient, "WriteOffReasons"); + + setPaymentChannelMappings.accept(List.of(new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(paymentTypeId) + .fundSourceAccountId(fundSourceAccountId))); + setFeeMappings.accept( + List.of(new WorkingCapitalLoanProductChargeToGLAccountMapper().chargeId(feeChargeId).incomeAccountId(feeIncomeAccountId))); + setPenaltyMappings.accept(List.of( + new WorkingCapitalLoanProductChargeToGLAccountMapper().chargeId(penaltyChargeId).incomeAccountId(penaltyIncomeAccountId))); + setChargeOffMappings.accept(List.of(new WorkingCapitalPostChargeOffReasonToExpenseAccountMappings() + .chargeOffReasonCodeValueId(chargeOffReasonId).expenseAccountId(writeOffExpenseAccountId))); + setWriteOffMappings.accept(List.of(new WorkingCapitalPostWriteOffReasonToExpenseAccountMappings() + .writeOffReasonCodeValueId(writeOffReasonId).expenseAccountId(writeOffExpenseAccountId))); + + return buildExpectation(paymentTypeId, fundSourceAccountId, feeChargeId, feeIncomeAccountId, penaltyChargeId, + penaltyIncomeAccountId, chargeOffReasonId, writeOffExpenseAccountId, writeOffReasonId, writeOffExpenseAccountId, + fineractFeignClient); + } + + private static AdvancedAccountingExpectation buildExpectation(final Long paymentTypeId, final Long fundSourceAccountId, + final Long feeChargeId, final Long feeIncomeAccountId, final Long penaltyChargeId, final Long penaltyIncomeAccountId, + final Long chargeOffReasonId, final Long chargeOffExpenseAccountId, final Long writeOffReasonId, + final Long writeOffExpenseAccountId, final FineractFeignClient fineractFeignClient) { + final List glAccounts = ok( + () -> fineractFeignClient.generalLedgerAccount().retrieveAllAccountsUniversal(Map.of())); + final Map accountNameById = new HashMap<>(); + for (GetGLAccountsResponse account : glAccounts) { + if (account != null && account.getId() != null && account.getName() != null) { + accountNameById.putIfAbsent(account.getId(), account.getName()); + } + } + final String paymentTypeName = DefaultPaymentType.MONEY_TRANSFER.getName(); + final String fundSourceAccountName = accountNameById.get(fundSourceAccountId); + final String feeIncomeAccountName = accountNameById.get(feeIncomeAccountId); + final String penaltyIncomeAccountName = accountNameById.get(penaltyIncomeAccountId); + final String chargeOffExpenseAccountName = accountNameById.get(chargeOffExpenseAccountId); + final String writeOffExpenseAccountName = accountNameById.get(writeOffExpenseAccountId); + + return new AdvancedAccountingExpectation(paymentTypeId, paymentTypeName, fundSourceAccountId, fundSourceAccountName, feeChargeId, + feeIncomeAccountId, feeIncomeAccountName, penaltyChargeId, penaltyIncomeAccountId, penaltyIncomeAccountName, + chargeOffReasonId, chargeOffExpenseAccountId, chargeOffExpenseAccountName, writeOffReasonId, writeOffExpenseAccountId, + writeOffExpenseAccountName); + } + + public static void assertProductHasExpectedAdvancedMappings(final ObjectMapper objectMapper, + final GetWorkingCapitalLoanProductsProductIdResponse product, final AdvancedAccountingExpectation expected) { + final JsonNode root = toTree(objectMapper, product); + assertProductHasAdvancedMappings(objectMapper, product); + + assertThat(root.path("paymentChannelToFundSourceMappings").path(0).path("paymentType").path("id").asLong()) + .as("paymentType.id mismatch").isEqualTo(expected.paymentTypeId()); + assertThat(root.path("paymentChannelToFundSourceMappings").path(0).path("paymentType").path("name").asText()) + .as("paymentType.name mismatch").isEqualTo(expected.paymentTypeName()); + assertThat(root.path("paymentChannelToFundSourceMappings").path(0).path("fundSourceAccount").path("id").asLong()) + .as("fundSourceAccount.id mismatch").isEqualTo(expected.fundSourceAccountId()); + assertThat(root.path("paymentChannelToFundSourceMappings").path(0).path("fundSourceAccount").path("name").asText()) + .as("fundSourceAccount.name mismatch").isEqualTo(expected.fundSourceAccountName()); + + assertThat(root.path("feeToIncomeAccountMappings").path(0).path("charge").path("id").asLong()).as("fee charge.id mismatch") + .isEqualTo(expected.feeChargeId()); + assertThat(root.path("feeToIncomeAccountMappings").path(0).path("incomeAccount").path("id").asLong()) + .as("fee incomeAccount.id mismatch").isEqualTo(expected.feeIncomeAccountId()); + assertThat(root.path("feeToIncomeAccountMappings").path(0).path("incomeAccount").path("name").asText()) + .as("fee incomeAccount.name mismatch").isEqualTo(expected.feeIncomeAccountName()); + + assertThat(root.path("penaltyToIncomeAccountMappings").path(0).path("charge").path("id").asLong()).as("penalty charge.id mismatch") + .isEqualTo(expected.penaltyChargeId()); + assertThat(root.path("penaltyToIncomeAccountMappings").path(0).path("incomeAccount").path("id").asLong()) + .as("penalty incomeAccount.id mismatch").isEqualTo(expected.penaltyIncomeAccountId()); + assertThat(root.path("penaltyToIncomeAccountMappings").path(0).path("incomeAccount").path("name").asText()) + .as("penalty incomeAccount.name mismatch").isEqualTo(expected.penaltyIncomeAccountName()); + + assertThat(root.path("chargeOffReasonToExpenseAccountMappings").path(0).path("reasonCodeValue").path("id").asLong()) + .as("chargeOff reason id mismatch").isEqualTo(expected.chargeOffReasonId()); + assertThat(root.path("chargeOffReasonToExpenseAccountMappings").path(0).path("expenseAccount").path("id").asLong()) + .as("chargeOff expenseAccount.id mismatch").isEqualTo(expected.chargeOffExpenseAccountId()); + assertThat(root.path("chargeOffReasonToExpenseAccountMappings").path(0).path("expenseAccount").path("name").asText()) + .as("chargeOff expenseAccount.name mismatch").isEqualTo(expected.chargeOffExpenseAccountName()); + + assertThat(root.path("writeOffReasonsToExpenseMappings").path(0).path("reasonCodeValue").path("id").asLong()) + .as("writeOff reason id mismatch").isEqualTo(expected.writeOffReasonId()); + assertThat(root.path("writeOffReasonsToExpenseMappings").path(0).path("expenseAccount").path("id").asLong()) + .as("writeOff expenseAccount.id mismatch").isEqualTo(expected.writeOffExpenseAccountId()); + assertThat(root.path("writeOffReasonsToExpenseMappings").path(0).path("expenseAccount").path("name").asText()) + .as("writeOff expenseAccount.name mismatch").isEqualTo(expected.writeOffExpenseAccountName()); + } + + private static Long createChargeForAdvancedMappings(final FineractFeignClient fineractFeignClient, final boolean penalty, + final double amount) { + final ChargeRequest request = new ChargeRequest().active(true).name(Utils.randomStringGenerator("WCLP_ADV_CHARGE_", 8)) + .chargeAppliesTo(1).chargeCalculationType(ChargeCalculationType.FLAT.value) + .chargeTimeType(ChargeTimeType.SPECIFIED_DUE_DATE.value).chargePaymentMode(ChargePaymentMode.REGULAR.value).penalty(penalty) + .amount(amount).currencyCode("USD").locale("en"); + final PostChargesResponse response = ok(() -> fineractFeignClient.charges().createCharge(request)); + return response.getResourceId(); + } + + private static Long resolveOrCreateGLAccount(final FineractFeignClient fineractFeignClient, final String name, final String glCode, + final Integer type) { + final Function, GetGLAccountsResponse> byNameOrCode = accounts -> accounts.stream() + .filter(a -> name.equals(a.getName()) || glCode.equals(a.getGlCode())).findFirst().orElse(null); + List accounts = ok(() -> fineractFeignClient.generalLedgerAccount().retrieveAllAccountsUniversal(Map.of())); + GetGLAccountsResponse existing = byNameOrCode.apply(accounts); + if (existing != null) { + return existing.getId(); + } + + final PostGLAccountsRequest request = GLAccountRequestFactory.defaultGLAccountRequest(name, glCode, type, GLAUsage.DETAIL.value, + true); + executeVoid(() -> fineractFeignClient.generalLedgerAccount().createGLAccount(request, Map.of())); + + accounts = ok(() -> fineractFeignClient.generalLedgerAccount().retrieveAllAccountsUniversal(Map.of())); + existing = byNameOrCode.apply(accounts); + if (existing != null) { + return existing.getId(); + } + throw new IllegalStateException("Unable to resolve or create GL account: " + name + " / " + glCode); + } + + private static Long resolveFirstCodeValueId(final FineractFeignClient fineractFeignClient, final String codeName) { + final List codeValues = ok( + () -> fineractFeignClient.codeValues().retrieveAllCodeValuesByCodeName(codeName, Map.of())); + if (codeValues == null || codeValues.isEmpty()) { + throw new IllegalStateException("No code values found for code: " + codeName); + } + return codeValues.getFirst().getId(); + } + + private static void assertNonEmptyArray(final JsonNode root, final String field) { + final JsonNode node = root.path(field); + assertThat(node.isArray()).as(field + " must be present").isTrue(); + assertThat(node.isEmpty()).as(field + " must not be empty").isFalse(); + } + + private static JsonNode toTree(final ObjectMapper objectMapper, final Object value) { + return objectMapper.valueToTree(value); + } + + public record AdvancedAccountingExpectation(Long paymentTypeId, String paymentTypeName, Long fundSourceAccountId, + String fundSourceAccountName, Long feeChargeId, Long feeIncomeAccountId, String feeIncomeAccountName, Long penaltyChargeId, + Long penaltyIncomeAccountId, String penaltyIncomeAccountName, Long chargeOffReasonId, Long chargeOffExpenseAccountId, + String chargeOffExpenseAccountName, Long writeOffReasonId, Long writeOffExpenseAccountId, String writeOffExpenseAccountName) { + } + +} diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java index 25de5c22026..f9178863f41 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java @@ -25,6 +25,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import io.cucumber.datatable.DataTable; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; @@ -36,6 +37,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.ObjectMapperFactory; import org.apache.fineract.client.feign.services.WorkingCapitalLoanProductsApi; import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.models.CommandProcessingResult; @@ -47,6 +49,7 @@ import org.apache.fineract.client.models.GetWorkingCapitalLoanProductsResponse; import org.apache.fineract.client.models.GetWorkingCapitalLoanProductsTemplateResponse; import org.apache.fineract.client.models.InternalWorkingCapitalLoanPaymentRequest; +import org.apache.fineract.client.models.PaymentTypeToGLAccountMapper; import org.apache.fineract.client.models.PostAllowAttributeOverrides; import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest; import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest.AccountingRuleEnum; @@ -56,9 +59,13 @@ import org.apache.fineract.client.models.PutWorkingCapitalLoanProductsProductIdResponse; import org.apache.fineract.client.models.StringEnumOptionData; import org.apache.fineract.client.models.WorkingCapitalBreachRequest; +import org.apache.fineract.client.models.WorkingCapitalLoanPaymentChannelToFundSourceMappings; import org.apache.fineract.client.models.WorkingCapitalNearBreachRequest; +import org.apache.fineract.client.models.WorkingCapitalPostChargeOffReasonToExpenseAccountMappings; +import org.apache.fineract.client.models.WorkingCapitalPostWriteOffReasonToExpenseAccountMappings; import org.apache.fineract.test.data.accounttype.AccountTypeResolver; import org.apache.fineract.test.data.accounttype.DefaultAccountType; +import org.apache.fineract.test.data.paymenttype.PaymentTypeResolver; import org.apache.fineract.test.data.workingcapitalproduct.DefaultWorkingCapitalLoanProduct; import org.apache.fineract.test.data.workingcapitalproduct.WCGLAccountMapping; import org.apache.fineract.test.data.workingcapitalproduct.WorkingCapitalBreachFrequencyType; @@ -66,6 +73,8 @@ import org.apache.fineract.test.factory.WorkingCapitalRequestFactory; import org.apache.fineract.test.helper.ErrorMessageHelper; import org.apache.fineract.test.helper.Utils; +import org.apache.fineract.test.helper.WorkingCapitalLoanProductAdvancedAccountingTestHelper; +import org.apache.fineract.test.helper.WorkingCapitalLoanProductAdvancedAccountingTestHelper.AdvancedAccountingExpectation; import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.support.TestContextKey; import org.assertj.core.api.SoftAssertions; @@ -78,6 +87,11 @@ public class WorkingCapitalStepDef extends AbstractStepDef { private final WorkingCapitalRequestFactory workingCapitalRequestFactory; private final FineractFeignClient fineractFeignClient; private final AccountTypeResolver accountTypeResolver; + private final PaymentTypeResolver paymentTypeResolver; + private static final ObjectMapper OBJECT_MAPPER = ObjectMapperFactory.getShared(); + private static final String WC_ADVANCED_MAPPINGS_EXPECTED_CREATE = "wcAdvancedMappingsExpectedCreate"; + private static final String WC_ADVANCED_MAPPINGS_EXPECTED_UPDATE = "wcAdvancedMappingsExpectedUpdate"; + private static final String WC_ADVANCED_MAPPINGS_EXPECTED_FIRST_UPDATE = "wcAdvancedMappingsExpectedFirstUpdate"; public static final String NAME_FIELD_NAME = "name"; public static final String SHORT_NAME_FIELD = "shortName"; @@ -106,6 +120,29 @@ public class WorkingCapitalStepDef extends AbstractStepDef { private static final long NON_EXISTENT_GL_ACCOUNT_ID = 999999L; + // GL Account IDs for advanced accounting mappings + private static final Long DEFAULT_PAYMENT_TYPE_ID = 1L; + private static final Long DEFAULT_FUND_SOURCE_ACCOUNT_ID = 1L; + private static final Long DEFAULT_LOAN_PORTFOLIO_ACCOUNT_ID = 1L; + private static final Long DEFAULT_TRANSFERS_IN_SUSPENSE_ACCOUNT_ID = 21L; + private static final Long DEFAULT_DEFERRED_INCOME_LIABILITY_ACCOUNT_ID = 22L; + private static final Long DEFAULT_INCOME_FROM_DISCOUNT_FEE_ACCOUNT_ID = 10L; + private static final Long DEFAULT_INCOME_FROM_FEE_ACCOUNT_ID = 10L; + private static final Long DEFAULT_INCOME_FROM_PENALTY_ACCOUNT_ID = 9L; + private static final Long DEFAULT_INCOME_FROM_RECOVERY_ACCOUNT_ID = 15L; + private static final Long DEFAULT_WRITE_OFF_ACCOUNT_ID = 16L; + private static final Long DEFAULT_OVERPAYMENT_LIABILITY_ACCOUNT_ID = 17L; + + // Code Value IDs for advanced mappings + private static final Long DEFAULT_CHARGE_OFF_REASON_CODE_VALUE_ID = 29L; + private static final Long DEFAULT_CHARGE_OFF_EXPENSE_ACCOUNT_ID_FOR_MAPPING = 23L; + private static final Long DEFAULT_WRITE_OFF_REASON_CODE_VALUE_ID = 66L; + private static final Long DEFAULT_WRITE_OFF_EXPENSE_ACCOUNT_ID_FOR_MAPPING = 23L; + + // Alternative IDs for duplicate testing + private static final Long ALTERNATIVE_PAYMENT_TYPE_ID = 2L; + private static final Long ALTERNATIVE_FUND_SOURCE_ACCOUNT_ID = 2L; + private WorkingCapitalLoanProductsApi workingCapitalApi() { return fineractFeignClient.workingCapitalLoanProducts(); } @@ -1696,6 +1733,58 @@ public void retrieveProductTemplate() { testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_TEMPLATE_RESPONSE, template); } + @When("Admin creates a new Working Capital Loan Product with Cash based accounting and advanced mappings") + public void createWorkingCapitalLoanProductWithAdvancedMappings() { + final String productName = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", 10); + final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory + .defaultWorkingCapitalLoanProductRequestWithCashAccounting().name(productName); + + final AdvancedAccountingExpectation expected = WorkingCapitalLoanProductAdvancedAccountingTestHelper + .prepareAdvancedMappings(request, paymentTypeResolver, fineractFeignClient); + testContext().set(WC_ADVANCED_MAPPINGS_EXPECTED_CREATE, expected); + + final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request); + } + + @When("Admin updates Working Capital Loan Product with advanced mappings") + public void updateWorkingCapitalLoanProductWithAdvancedMappings() { + final PostWorkingCapitalLoanProductsResponse createResponse = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE); + final Long resourceId = createResponse.getResourceId(); + final PostWorkingCapitalLoanProductsRequest cashRequest = workingCapitalRequestFactory + .defaultWorkingCapitalLoanProductRequestWithCashAccounting(); + final PutWorkingCapitalLoanProductsProductIdRequest updateRequest = buildCashBasedUpdateRequest(cashRequest); + final AdvancedAccountingExpectation expected = WorkingCapitalLoanProductAdvancedAccountingTestHelper + .prepareAdvancedMappings(updateRequest, paymentTypeResolver, fineractFeignClient); + testContext().set(WC_ADVANCED_MAPPINGS_EXPECTED_UPDATE, expected); + + final PutWorkingCapitalLoanProductsProductIdResponse response = ok( + () -> workingCapitalApi().updateWorkingCapitalLoanProduct(resourceId, updateRequest, Map.of())); + + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_UPDATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_UPDATE_REQUEST, updateRequest); + } + + @When("Admin updates Working Capital Loan Product with advanced mappings twice") + public void updateWorkingCapitalLoanProductWithAdvancedMappingsTwice() { + final PostWorkingCapitalLoanProductsResponse createResponse = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE); + final Long resourceId = createResponse.getResourceId(); + + final PutWorkingCapitalLoanProductsProductIdRequest firstUpdateRequest = buildAdvancedMappingsUpdateRequest(); + ok(() -> workingCapitalApi().updateWorkingCapitalLoanProduct(resourceId, firstUpdateRequest, Map.of())); + testContext().set(WC_ADVANCED_MAPPINGS_EXPECTED_FIRST_UPDATE, testContext().get(WC_ADVANCED_MAPPINGS_EXPECTED_UPDATE)); + + final PutWorkingCapitalLoanProductsProductIdRequest secondUpdateRequest = buildAdvancedMappingsUpdateRequest(); + final PutWorkingCapitalLoanProductsProductIdResponse response = ok( + () -> workingCapitalApi().updateWorkingCapitalLoanProduct(resourceId, secondUpdateRequest, Map.of())); + + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_UPDATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_UPDATE_REQUEST, secondUpdateRequest); + } + @Then("Working Capital Loan Product template has delinquencyStartTypeOptions containing:") public void verifyTemplateDelinquencyStartTypeOptions(final DataTable table) { final List expectedOptions = table.asList(); @@ -1706,4 +1795,211 @@ public void verifyTemplateDelinquencyStartTypeOptions(final DataTable table) { assertThat(actualCodes).containsAll(expectedOptions); } + @Then("Working Capital Loan Product template has advanced accounting options") + public void verifyTemplateAdvancedAccountingOptions() { + final GetWorkingCapitalLoanProductsTemplateResponse template = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_TEMPLATE_RESPONSE); + WorkingCapitalLoanProductAdvancedAccountingTestHelper.assertTemplateHasOptions(template); + } + + @Then("Working Capital Loan Product has advanced accounting mappings") + public void verifyProductHasAdvancedAccountingMappings() { + final GetWorkingCapitalLoanProductsProductIdResponse product = retrieveCreatedProduct(); + final AdvancedAccountingExpectation expected = testContext().get().containsKey(WC_ADVANCED_MAPPINGS_EXPECTED_UPDATE) + ? testContext().get(WC_ADVANCED_MAPPINGS_EXPECTED_UPDATE) + : testContext().get(WC_ADVANCED_MAPPINGS_EXPECTED_CREATE); + WorkingCapitalLoanProductAdvancedAccountingTestHelper.assertProductHasExpectedAdvancedMappings(OBJECT_MAPPER, product, expected); + } + + @Then("Working Capital Loan Product has latest advanced accounting mappings after second update") + public void verifyProductHasLatestAdvancedAccountingMappingsAfterSecondUpdate() { + final GetWorkingCapitalLoanProductsProductIdResponse product = retrieveCreatedProduct(); + final AdvancedAccountingExpectation firstExpected = testContext().get(WC_ADVANCED_MAPPINGS_EXPECTED_FIRST_UPDATE); + final AdvancedAccountingExpectation secondExpected = testContext().get(WC_ADVANCED_MAPPINGS_EXPECTED_UPDATE); + + WorkingCapitalLoanProductAdvancedAccountingTestHelper.assertProductHasExpectedAdvancedMappings(OBJECT_MAPPER, product, + secondExpected); + assertThat(secondExpected.feeChargeId()).as("Fee mapping charge should be replaced on second update") + .isNotEqualTo(firstExpected.feeChargeId()); + assertThat(secondExpected.penaltyChargeId()).as("Penalty mapping charge should be replaced on second update") + .isNotEqualTo(firstExpected.penaltyChargeId()); + } + + private PutWorkingCapitalLoanProductsProductIdRequest buildAdvancedMappingsUpdateRequest() { + final PostWorkingCapitalLoanProductsRequest cashRequest = workingCapitalRequestFactory + .defaultWorkingCapitalLoanProductRequestWithCashAccounting(); + final PutWorkingCapitalLoanProductsProductIdRequest updateRequest = buildCashBasedUpdateRequest(cashRequest); + final AdvancedAccountingExpectation expected = WorkingCapitalLoanProductAdvancedAccountingTestHelper + .prepareAdvancedMappings(updateRequest, paymentTypeResolver, fineractFeignClient); + testContext().set(WC_ADVANCED_MAPPINGS_EXPECTED_UPDATE, expected); + return updateRequest; + } + + @When("Admin attempts to create Working Capital Loan Product with null paymentTypeId in payment channel mappings") + public void attemptCreateWithNullPaymentTypeId() { + List paymentChannelMappings = List + .of(new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(null) + .fundSourceAccountId(DEFAULT_FUND_SOURCE_ACCOUNT_ID)); + attemptCreateWithAdvancedMappings(paymentChannelMappings, buildDefaultChargeOffMappings(), buildDefaultWriteOffMappings()); + } + + @When("Admin attempts to create Working Capital Loan Product with null fundSourceAccountId in payment channel mappings") + public void attemptCreateWithNullFundSourceAccountId() { + List paymentChannelMappings = List + .of(new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(DEFAULT_PAYMENT_TYPE_ID) + .fundSourceAccountId(null)); + attemptCreateWithAdvancedMappings(paymentChannelMappings, buildDefaultChargeOffMappings(), buildDefaultWriteOffMappings()); + } + + @When("Admin attempts to create Working Capital Loan Product with null chargeOffReasonCodeValueId in charge-off mappings") + public void attemptCreateWithNullChargeOffReasonCodeValueId() { + List chargeOffMappings = List + .of(new WorkingCapitalPostChargeOffReasonToExpenseAccountMappings().chargeOffReasonCodeValueId(null) + .expenseAccountId(DEFAULT_CHARGE_OFF_EXPENSE_ACCOUNT_ID_FOR_MAPPING)); + attemptCreateWithAdvancedMappings(buildDefaultPaymentChannelMappings(), chargeOffMappings, buildDefaultWriteOffMappings()); + } + + @When("Admin attempts to create Working Capital Loan Product with null expenseAccountId in charge-off mappings") + public void attemptCreateWithNullChargeOffExpenseAccountId() { + List chargeOffMappings = List + .of(new WorkingCapitalPostChargeOffReasonToExpenseAccountMappings() + .chargeOffReasonCodeValueId(DEFAULT_CHARGE_OFF_REASON_CODE_VALUE_ID).expenseAccountId(null)); + attemptCreateWithAdvancedMappings(buildDefaultPaymentChannelMappings(), chargeOffMappings, buildDefaultWriteOffMappings()); + } + + @When("Admin attempts to create Working Capital Loan Product with null writeOffReasonCodeValueId in write-off mappings") + public void attemptCreateWithNullWriteOffReasonCodeValueId() { + List writeOffMappings = List + .of(new WorkingCapitalPostWriteOffReasonToExpenseAccountMappings().writeOffReasonCodeValueId(null) + .expenseAccountId(DEFAULT_WRITE_OFF_EXPENSE_ACCOUNT_ID_FOR_MAPPING)); + attemptCreateWithAdvancedMappings(buildDefaultPaymentChannelMappings(), buildDefaultChargeOffMappings(), writeOffMappings); + } + + @When("Admin attempts to create Working Capital Loan Product with null expenseAccountId in write-off mappings") + public void attemptCreateWithNullWriteOffExpenseAccountId() { + List writeOffMappings = List + .of(new WorkingCapitalPostWriteOffReasonToExpenseAccountMappings() + .writeOffReasonCodeValueId(DEFAULT_WRITE_OFF_REASON_CODE_VALUE_ID).expenseAccountId(null)); + attemptCreateWithAdvancedMappings(buildDefaultPaymentChannelMappings(), buildDefaultChargeOffMappings(), writeOffMappings); + } + + @When("Admin attempts to create Working Capital Loan Product with duplicate paymentTypeId in payment channel mappings") + public void attemptCreateWithDuplicatePaymentTypeId() { + List paymentChannelMappings = List.of( + new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(DEFAULT_PAYMENT_TYPE_ID) + .fundSourceAccountId(DEFAULT_FUND_SOURCE_ACCOUNT_ID), + new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(DEFAULT_PAYMENT_TYPE_ID) + .fundSourceAccountId(ALTERNATIVE_FUND_SOURCE_ACCOUNT_ID)); + attemptCreateWithAdvancedMappings(paymentChannelMappings, buildDefaultChargeOffMappings(), buildDefaultWriteOffMappings()); + } + + @When("Admin attempts to create Working Capital Loan Product with duplicate fundSourceAccountId in payment channel mappings") + public void attemptCreateWithDuplicateFundSourceAccountId() { + List paymentChannelMappings = List.of( + new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(DEFAULT_PAYMENT_TYPE_ID) + .fundSourceAccountId(DEFAULT_FUND_SOURCE_ACCOUNT_ID), + new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(ALTERNATIVE_PAYMENT_TYPE_ID) + .fundSourceAccountId(DEFAULT_FUND_SOURCE_ACCOUNT_ID)); + attemptCreateWithAdvancedMappings(paymentChannelMappings, buildDefaultChargeOffMappings(), buildDefaultWriteOffMappings()); + } + + @When("Admin creates Working Capital Loan Product with unique payment channel mappings") + public void createWithUniquePaymentChannelMappings() { + List paymentChannelMappings = List.of( + new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(DEFAULT_PAYMENT_TYPE_ID) + .fundSourceAccountId(DEFAULT_FUND_SOURCE_ACCOUNT_ID), + new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(ALTERNATIVE_PAYMENT_TYPE_ID) + .fundSourceAccountId(ALTERNATIVE_FUND_SOURCE_ACCOUNT_ID)); + PostWorkingCapitalLoanProductsRequest request = buildAdvancedMappingsRequest(paymentChannelMappings, + buildDefaultChargeOffMappings(), buildDefaultWriteOffMappings()); + PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + } + + private List buildDefaultPaymentChannelMappings() { + return List.of(new WorkingCapitalLoanPaymentChannelToFundSourceMappings().paymentTypeId(DEFAULT_PAYMENT_TYPE_ID) + .fundSourceAccountId(DEFAULT_FUND_SOURCE_ACCOUNT_ID)); + } + + private List buildDefaultChargeOffMappings() { + return List.of(new WorkingCapitalPostChargeOffReasonToExpenseAccountMappings() + .chargeOffReasonCodeValueId(DEFAULT_CHARGE_OFF_REASON_CODE_VALUE_ID) + .expenseAccountId(DEFAULT_CHARGE_OFF_EXPENSE_ACCOUNT_ID_FOR_MAPPING)); + } + + private List buildDefaultWriteOffMappings() { + return List.of(new WorkingCapitalPostWriteOffReasonToExpenseAccountMappings() + .writeOffReasonCodeValueId(DEFAULT_WRITE_OFF_REASON_CODE_VALUE_ID) + .expenseAccountId(DEFAULT_WRITE_OFF_EXPENSE_ACCOUNT_ID_FOR_MAPPING)); + } + + private PostWorkingCapitalLoanProductsRequest buildAdvancedMappingsRequest( + List paymentChannelMappings, + List chargeOffMappings, + List writeOffMappings) { + final String productName = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", 10); + return workingCapitalRequestFactory.defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest().name(productName) + .accountingRule(AccountingRuleEnum.CASH_BASED).fundSourceAccountId(DEFAULT_FUND_SOURCE_ACCOUNT_ID) + .loanPortfolioAccountId(DEFAULT_LOAN_PORTFOLIO_ACCOUNT_ID) + .transfersInSuspenseAccountId(DEFAULT_TRANSFERS_IN_SUSPENSE_ACCOUNT_ID) + .deferredIncomeLiabilityAccountId(DEFAULT_DEFERRED_INCOME_LIABILITY_ACCOUNT_ID) + .incomeFromDiscountFeeAccountId(DEFAULT_INCOME_FROM_DISCOUNT_FEE_ACCOUNT_ID) + .incomeFromFeeAccountId(DEFAULT_INCOME_FROM_FEE_ACCOUNT_ID) + .incomeFromPenaltyAccountId(DEFAULT_INCOME_FROM_PENALTY_ACCOUNT_ID) + .incomeFromRecoveryAccountId(DEFAULT_INCOME_FROM_RECOVERY_ACCOUNT_ID).writeOffAccountId(DEFAULT_WRITE_OFF_ACCOUNT_ID) + .overpaymentLiabilityAccountId(DEFAULT_OVERPAYMENT_LIABILITY_ACCOUNT_ID) + .paymentChannelToFundSourceMappings(paymentChannelMappings).chargeOffReasonToExpenseAccountMappings(chargeOffMappings) + .writeOffReasonsToExpenseMappings(writeOffMappings).feeToIncomeAccountMappings(List.of()) + .penaltyToIncomeAccountMappings(List.of()); + } + + private void attemptCreateWithAdvancedMappings(List paymentChannelMappings, + List chargeOffMappings, + List writeOffMappings) { + PostWorkingCapitalLoanProductsRequest request = buildAdvancedMappingsRequest(paymentChannelMappings, chargeOffMappings, + writeOffMappings); + try { + createWorkingCapitalLoanProduct(request); + } catch (CallFailedRuntimeException e) { + testContext().set(TestContextKey.ERROR_RESPONSE, e); + } + } + + @Then("Admin gets validation error with status code {int} and message {string}") + public void validateErrorResponse(int expectedStatusCode, String expectedErrorMessage) { + CallFailedRuntimeException exception = testContext().get(TestContextKey.ERROR_RESPONSE); + assertThat(exception).as(ErrorMessageHelper.incorrectExpectedValueInResponse()).isNotNull(); + assertThat(exception.getStatus()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()).isEqualTo(expectedStatusCode); + assertThat(exception.getDeveloperMessage()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()) + .contains(expectedErrorMessage); + } + + @Then("Working Capital Loan Product is created successfully with two payment channel mappings") + public void verifyWorkingCapitalLoanProductCreatedWithTwoMappings() { + PostWorkingCapitalLoanProductsResponse response = testContext().get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE); + assertThat(response).as(ErrorMessageHelper.incorrectExpectedValueInResponse()).isNotNull(); + assertThat(response.getResourceId()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()).isNotNull(); + assertThat(response.getResourceId()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()).isGreaterThan(0L); + + Long productId = response.getResourceId(); + GetWorkingCapitalLoanProductsProductIdResponse productDetails = ok( + () -> workingCapitalApi().retrieveOneWorkingCapitalLoanProduct(productId, Map.of())); + + assertThat(productDetails).as(ErrorMessageHelper.incorrectExpectedValueInResponse()).isNotNull(); + assertThat(productDetails.getPaymentChannelToFundSourceMappings()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()) + .isNotNull(); + assertThat(productDetails.getPaymentChannelToFundSourceMappings()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()) + .hasSize(2); + + List mappings = productDetails.getPaymentChannelToFundSourceMappings(); + assertThat(mappings.get(0).getPaymentType().getId()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()) + .isEqualTo(DEFAULT_PAYMENT_TYPE_ID); + assertThat(mappings.get(0).getFundSourceAccount().getId()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()) + .isEqualTo(DEFAULT_FUND_SOURCE_ACCOUNT_ID); + assertThat(mappings.get(1).getPaymentType().getId()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()) + .isEqualTo(ALTERNATIVE_PAYMENT_TYPE_ID); + assertThat(mappings.get(1).getFundSourceAccount().getId()).as(ErrorMessageHelper.incorrectExpectedValueInResponse()) + .isEqualTo(ALTERNATIVE_FUND_SOURCE_ACCOUNT_ID); + } + } diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanProductAdvancedAccounting.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanProductAdvancedAccounting.feature new file mode 100644 index 00000000000..dd88a4922e8 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanProductAdvancedAccounting.feature @@ -0,0 +1,64 @@ +@WorkingCapitalLoanProductAdvancedAccountingFeature +Feature: WorkingCapitalLoanProductAdvancedAccounting + + @TestRailId:C76759 + Scenario: Verify WC Loan Product template includes advanced accounting options + When Admin retrieves the Working Capital Loan Product template + Then Working Capital Loan Product template has advanced accounting options + + @TestRailId:C76760 + Scenario: Verify WC Loan Product create persists advanced accounting mappings + When Admin creates a new Working Capital Loan Product with Cash based accounting and advanced mappings + Then Working Capital Loan Product has advanced accounting mappings + + @TestRailId:C76761 + Scenario: Verify WC Loan Product update persists advanced accounting mappings + When Admin creates a new Working Capital Loan Product with accounting rule "CASH_BASED" + And Admin updates Working Capital Loan Product with advanced mappings + Then Working Capital Loan Product has advanced accounting mappings + + @TestRailId:C76762 + Scenario: Verify WC Loan Product second update overrides existing advanced mappings + When Admin creates a new Working Capital Loan Product with accounting rule "CASH_BASED" + And Admin updates Working Capital Loan Product with advanced mappings twice + Then Working Capital Loan Product has latest advanced accounting mappings after second update + + @TestRailId:C78827 + Scenario: Verify validation error when paymentTypeId is null in payment channel mappings + When Admin attempts to create Working Capital Loan Product with null paymentTypeId in payment channel mappings + Then Admin gets validation error with status code 400 and message "paymentTypeId is mandatory" + + @TestRailId:C78828 + Scenario: Verify validation error when fundSourceAccountId is null in payment channel mappings + When Admin attempts to create Working Capital Loan Product with null fundSourceAccountId in payment channel mappings + Then Admin gets validation error with status code 400 and message "fundSourceAccountId is mandatory" + + @TestRailId:C78829 + Scenario: Verify validation error when chargeOffReasonCodeValueId is null in charge-off mappings + When Admin attempts to create Working Capital Loan Product with null chargeOffReasonCodeValueId in charge-off mappings + Then Admin gets validation error with status code 400 and message "chargeOffReasonCodeValueId is mandatory" + + @TestRailId:C78830 + Scenario: Verify validation error when expenseAccountId is null in charge-off mappings + When Admin attempts to create Working Capital Loan Product with null expenseAccountId in charge-off mappings + Then Admin gets validation error with status code 400 and message "expenseGlAccountId is mandatory" + + @TestRailId:C78831 + Scenario: Verify validation error when writeOffReasonCodeValueId is null in write-off mappings + When Admin attempts to create Working Capital Loan Product with null writeOffReasonCodeValueId in write-off mappings + Then Admin gets validation error with status code 400 and message "writeOffReasonCodeValueId is mandatory" + + @TestRailId:C78832 + Scenario: Verify validation error when expenseAccountId is null in write-off mappings + When Admin attempts to create Working Capital Loan Product with null expenseAccountId in write-off mappings + Then Admin gets validation error with status code 400 and message "expenseGlAccountId is mandatory" + + @TestRailId:C78833 + Scenario: Verify validation error when duplicate paymentTypeId exists in payment channel mappings + When Admin attempts to create Working Capital Loan Product with duplicate paymentTypeId in payment channel mappings + Then Admin gets validation error with status code 400 and message "Duplicated entry for paymentChannelToFundSourceMappings.paymentTypeId" + + @TestRailId:C78835 + Scenario: Verify successful creation with unique payment channel mappings, multiple elements + When Admin creates Working Capital Loan Product with unique payment channel mappings + Then Working Capital Loan Product is created successfully with two payment channel mappings 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 ce1f27df57a..96a1f241324 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 @@ -74,4 +74,7 @@ private WorkingCapitalLoanConstants() { public static final String receiptNumberParamName = "receiptNumber"; public static final String bankNumberParamName = "bankNumber"; public static final String transactionDateParamName = "transactionDate"; + + public static final String WRITE_OFF_REASONS = "WriteOffReasons"; + public static final String CHARGE_OFF_REASONS = "ChargeOffReasons"; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java index 3f5514d10f9..612f43c2020 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java @@ -90,6 +90,11 @@ private WorkingCapitalLoanProductConstants() { public static final String goodwillCreditAccountIdParamName = "goodwillCreditAccountId"; public static final String chargeOffExpenseAccountIdParamName = "chargeOffExpenseAccountId"; public static final String chargeOffFraudExpenseAccountIdParamName = "chargeOffFraudExpenseAccountId"; + public static final String paymentChannelToFundSourceMappingsParamName = "paymentChannelToFundSourceMappings"; + public static final String feeToIncomeAccountMappingsParamName = "feeToIncomeAccountMappings"; + public static final String penaltyToIncomeAccountMappingsParamName = "penaltyToIncomeAccountMappings"; + public static final String chargeOffReasonToExpenseAccountMappingsParamName = "chargeOffReasonToExpenseAccountMappings"; + public static final String writeOffReasonsToExpenseMappingsParamName = "writeOffReasonsToExpenseMappings"; // Near Breach public static final String nearBreachNameParamName = "nearBreachName"; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java index 2095f0dc339..ab38474ba2d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java @@ -24,10 +24,16 @@ import java.util.List; import java.util.Map; import org.apache.fineract.accounting.glaccount.data.GLAccountData; +import org.apache.fineract.accounting.producttoaccountmapping.data.AdvancedMappingToExpenseAccountData; +import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeToGLAccountMapper; +import org.apache.fineract.accounting.producttoaccountmapping.data.PaymentTypeToGLAccountMapper; +import org.apache.fineract.infrastructure.codes.data.CodeValueData; import org.apache.fineract.infrastructure.core.data.EnumOptionData; import org.apache.fineract.infrastructure.core.data.StringEnumOptionData; import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.charge.data.ChargeData; import org.apache.fineract.portfolio.fund.data.FundData; +import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; import org.apache.fineract.portfolio.workingcapitalloanbreach.data.WorkingCapitalBreachData; import org.apache.fineract.portfolio.workingcapitalloannearbreach.data.WorkingCapitalNearBreachData; @@ -162,6 +168,11 @@ private PostWorkingCapitalLoanProductsRequest() {} public Long chargeOffExpenseAccountId; @Schema(example = "18") public Long chargeOffFraudExpenseAccountId; + public List paymentChannelToFundSourceMappings; + public List feeToIncomeAccountMappings; + public List penaltyToIncomeAccountMappings; + public List chargeOffReasonToExpenseAccountMappings; + public List writeOffReasonsToExpenseMappings; @Schema(example = "en_GB") public String locale; @@ -207,6 +218,42 @@ private PostAllowAttributeOverrides() {} @Schema(example = "true") public Boolean periodPaymentFrequencyType; } + + @Schema(description = "WorkingCapitalLoanPaymentChannelToFundSourceMappings") + public static final class WorkingCapitalLoanPaymentChannelToFundSourceMappings { + + private WorkingCapitalLoanPaymentChannelToFundSourceMappings() {} + + public Long paymentTypeId; + public Long fundSourceAccountId; + } + + @Schema(description = "WorkingCapitalLoanProductChargeToGLAccountMapper") + public static final class WorkingCapitalLoanProductChargeToGLAccountMapper { + + private WorkingCapitalLoanProductChargeToGLAccountMapper() {} + + public Long chargeId; + public Long incomeAccountId; + } + + @Schema(description = "WorkingCapitalPostChargeOffReasonToExpenseAccountMappings") + public static final class WorkingCapitalPostChargeOffReasonToExpenseAccountMappings { + + private WorkingCapitalPostChargeOffReasonToExpenseAccountMappings() {} + + public Long chargeOffReasonCodeValueId; + public Long expenseAccountId; + } + + @Schema(description = "WorkingCapitalPostWriteOffReasonToExpenseAccountMappings") + public static final class WorkingCapitalPostWriteOffReasonToExpenseAccountMappings { + + private WorkingCapitalPostWriteOffReasonToExpenseAccountMappings() {} + + public Long writeOffReasonCodeValueId; + public Long expenseAccountId; + } } @Schema(description = "PostWorkingCapitalLoanProductsResponse") @@ -285,6 +332,11 @@ private GetWorkingCapitalLoanProductsResponse() {} // Accounting public StringEnumOptionData accountingRule; public Map accountingMappings; + public List paymentChannelToFundSourceMappings; + public List feeToIncomeAccountMappings; + public List penaltyToIncomeAccountMappings; + public List chargeOffReasonToExpenseAccountMappings; + public List writeOffReasonsToExpenseMappings; @Schema(description = "GetDelinquencyBucket") public static final class GetDelinquencyBucket { @@ -375,6 +427,9 @@ public static final class GetWorkingCapitalLoanProductsTemplateResponse { private GetWorkingCapitalLoanProductsTemplateResponse() {} public List fundOptions; + public List paymentTypeOptions; + public List chargeOptions; + public List penaltyOptions; public List currencyOptions; public List amortizationTypeOptions; public List periodFrequencyTypeOptions; @@ -387,6 +442,8 @@ private GetWorkingCapitalLoanProductsTemplateResponse() {} public List delinquencyBucketOptions; public List accountingRuleOptions; public Map accountingMappingOptions; + public List chargeOffReasonOptions; + public List writeOffReasonOptions; } @Schema(description = "GetWorkingCapitalLoanProductsProductIdResponse") @@ -456,6 +513,11 @@ private GetWorkingCapitalLoanProductsProductIdResponse() {} // Accounting public StringEnumOptionData accountingRule; public Map accountingMappings; + public List paymentChannelToFundSourceMappings; + public List feeToIncomeAccountMappings; + public List penaltyToIncomeAccountMappings; + public List chargeOffReasonToExpenseAccountMappings; + public List writeOffReasonsToExpenseMappings; } @Schema(description = "PutWorkingCapitalLoanProductsProductIdRequest") @@ -566,6 +628,11 @@ private PutWorkingCapitalLoanProductsProductIdRequest() {} public Long chargeOffExpenseAccountId; @Schema(example = "18") public Long chargeOffFraudExpenseAccountId; + public List paymentChannelToFundSourceMappings; + public List feeToIncomeAccountMappings; + public List penaltyToIncomeAccountMappings; + public List chargeOffReasonToExpenseAccountMappings; + public List writeOffReasonsToExpenseMappings; @Schema(example = "en_GB") public String locale; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java index 0eea29159aa..3f25bf2c4cf 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java @@ -30,11 +30,17 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.apache.fineract.accounting.glaccount.data.GLAccountData; +import org.apache.fineract.accounting.producttoaccountmapping.data.AdvancedMappingToExpenseAccountData; +import org.apache.fineract.accounting.producttoaccountmapping.data.ChargeToGLAccountMapper; +import org.apache.fineract.accounting.producttoaccountmapping.data.PaymentTypeToGLAccountMapper; +import org.apache.fineract.infrastructure.codes.data.CodeValueData; import org.apache.fineract.infrastructure.core.data.EnumOptionData; import org.apache.fineract.infrastructure.core.data.StringEnumOptionData; import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.charge.data.ChargeData; import org.apache.fineract.portfolio.delinquency.data.DelinquencyBucketData; import org.apache.fineract.portfolio.fund.data.FundData; +import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; import org.apache.fineract.portfolio.workingcapitalloanbreach.data.WorkingCapitalBreachData; import org.apache.fineract.portfolio.workingcapitalloannearbreach.data.WorkingCapitalNearBreachData; @@ -89,9 +95,17 @@ public class WorkingCapitalLoanProductData implements Serializable { // Accounting private StringEnumOptionData accountingRule; private Map accountingMappings; + private Collection paymentChannelToFundSourceMappings; + private Collection feeToIncomeAccountMappings; + private Collection penaltyToIncomeAccountMappings; + private List chargeOffReasonToExpenseAccountMappings; + private List writeOffReasonsToExpenseMappings; // Template related private Collection fundOptions; + private Collection paymentTypeOptions; + private Collection chargeOptions; + private Collection penaltyOptions; private Collection currencyOptions; private List amortizationTypeOptions; private List periodFrequencyTypeOptions; @@ -104,6 +118,8 @@ public class WorkingCapitalLoanProductData implements Serializable { private List accountingRuleOptions; private Map> accountingMappingOptions; private List nearBreachOptions; + private List chargeOffReasonOptions; + private List writeOffReasonOptions; public WorkingCapitalLoanProductData applyTemplate(final WorkingCapitalLoanProductData productTemplate) { setFundOptions(productTemplate.getFundOptions()); @@ -117,6 +133,11 @@ public WorkingCapitalLoanProductData applyTemplate(final WorkingCapitalLoanProdu setDelinquencyStartTypeOptions(productTemplate.getDelinquencyStartTypeOptions()); setAccountingRuleOptions(productTemplate.getAccountingRuleOptions()); setAccountingMappingOptions(productTemplate.getAccountingMappingOptions()); + setPaymentTypeOptions(productTemplate.getPaymentTypeOptions()); + setChargeOptions(productTemplate.getChargeOptions()); + setPenaltyOptions(productTemplate.getPenaltyOptions()); + setChargeOffReasonOptions(productTemplate.getChargeOffReasonOptions()); + setWriteOffReasonOptions(productTemplate.getWriteOffReasonOptions()); setDelinquencyMinimumPaymentTypeOptions(productTemplate.getDelinquencyMinimumPaymentTypeOptions()); setNearBreachOptions(productTemplate.getNearBreachOptions()); return this; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java index 361e138aa69..0240c42e86c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java @@ -74,9 +74,17 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "delinquencyStartType", source = "relatedDetail.delinquencyStartType", qualifiedByName = "delinquencyStartTypeToStringEnumOptionData") @Mapping(target = "accountingRule", source = "accountingRule", qualifiedByName = "accountingRuleToStringEnumOptionData") @Mapping(target = "accountingMappings", ignore = true) + @Mapping(target = "paymentChannelToFundSourceMappings", ignore = true) + @Mapping(target = "feeToIncomeAccountMappings", ignore = true) + @Mapping(target = "penaltyToIncomeAccountMappings", ignore = true) + @Mapping(target = "chargeOffReasonToExpenseAccountMappings", ignore = true) + @Mapping(target = "writeOffReasonsToExpenseMappings", ignore = true) @Mapping(target = "accountingRuleOptions", ignore = true) @Mapping(target = "accountingMappingOptions", ignore = true) @Mapping(target = "fundOptions", ignore = true) + @Mapping(target = "paymentTypeOptions", ignore = true) + @Mapping(target = "chargeOptions", ignore = true) + @Mapping(target = "penaltyOptions", ignore = true) @Mapping(target = "currencyOptions", ignore = true) @Mapping(target = "amortizationTypeOptions", ignore = true) @Mapping(target = "periodFrequencyTypeOptions", ignore = true) @@ -88,6 +96,8 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "delinquencyStartTypeOptions", ignore = true) @Mapping(target = "delinquencyMinimumPaymentTypeOptions", ignore = true) @Mapping(target = "nearBreachOptions", ignore = true) + @Mapping(target = "chargeOffReasonOptions", ignore = true) + @Mapping(target = "writeOffReasonOptions", ignore = true) WorkingCapitalLoanProductData toData(WorkingCapitalLoanProduct entity); List toDataList(List entities); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java index 3366c5d0275..1ec2570b34b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java @@ -120,7 +120,13 @@ public class WorkingCapitalLoanProductDataValidator { WorkingCapitalLoanProductConstants.chargeOffExpenseAccountIdParamName, // WorkingCapitalLoanProductConstants.chargeOffFraudExpenseAccountIdParamName, // WorkingCapitalLoanProductConstants.breachIdParamName, // - WorkingCapitalLoanProductConstants.nearBreachIdParamName // + WorkingCapitalLoanProductConstants.nearBreachIdParamName, // + WorkingCapitalLoanProductConstants.paymentChannelToFundSourceMappingsParamName, // + WorkingCapitalLoanProductConstants.feeToIncomeAccountMappingsParamName, // + WorkingCapitalLoanProductConstants.penaltyToIncomeAccountMappingsParamName, // + WorkingCapitalLoanProductConstants.chargeOffReasonToExpenseAccountMappingsParamName, // + WorkingCapitalLoanProductConstants.writeOffReasonsToExpenseMappingsParamName, // + WorkingCapitalLoanProductConstants.breachIdParamName // )); public void validateForCreate(final String json) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java index dbaae0cd25b..3e17b173ecc 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java @@ -24,6 +24,9 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.common.AccountingDropdownReadPlatformService; import org.apache.fineract.accounting.glaccount.data.GLAccountData; +import org.apache.fineract.accounting.producttoaccountmapping.service.WorkingCapitalLoanProductAdvancedAccountingReadHelper; +import org.apache.fineract.infrastructure.codes.data.CodeValueData; +import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService; import org.apache.fineract.infrastructure.core.api.ApiFacingEnum; import org.apache.fineract.infrastructure.core.data.EnumOptionData; import org.apache.fineract.infrastructure.core.data.StringEnumOptionData; @@ -36,6 +39,9 @@ import org.apache.fineract.portfolio.fund.data.FundData; import org.apache.fineract.portfolio.fund.service.FundReadPlatformService; import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType; +import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; +import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; +import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanbreach.data.WorkingCapitalBreachData; import org.apache.fineract.portfolio.workingcapitalloanbreach.service.WorkingCapitalBreachReadPlatformService; @@ -64,7 +70,10 @@ public class WorkingCapitalLoanProductReadPlatformServiceImpl implements Working private final CurrencyReadPlatformService currencyReadPlatformService; private final DelinquencyReadPlatformService delinquencyReadPlatformService; private final WorkingCapitalBreachReadPlatformService breachReadPlatformService; + private final PaymentTypeReadService paymentTypeReadService; private final AccountingDropdownReadPlatformService accountingDropdownReadPlatformService; + private final WorkingCapitalLoanProductAdvancedAccountingReadHelper advancedAccountingReadHelper; + private final CodeValueReadPlatformService codeValueReadPlatformService; private final WorkingCapitalProductAccountingMappingService wcAccountingMappingService; private final WorkingCapitalNearBreachReadPlatformService nearBreachReadPlatformService; @@ -84,6 +93,11 @@ public WorkingCapitalLoanProductData retrieveWorkingCapitalLoanProduct(final Lon final Map accountingMappings = this.wcAccountingMappingService.fetchAccountMappingDetails(productId, product.getAccountingRule()); productData.setAccountingMappings(accountingMappings); + productData.setPaymentChannelToFundSourceMappings(advancedAccountingReadHelper.fetchPaymentTypeToFundSourceMappings(productId)); + productData.setFeeToIncomeAccountMappings(advancedAccountingReadHelper.fetchFeeToIncomeMappings(productId)); + productData.setPenaltyToIncomeAccountMappings(advancedAccountingReadHelper.fetchPenaltyToIncomeMappings(productId)); + productData.setChargeOffReasonToExpenseAccountMappings(advancedAccountingReadHelper.fetchChargeOffReasonMappings(productId)); + productData.setWriteOffReasonsToExpenseMappings(advancedAccountingReadHelper.fetchWriteOffReasonMappings(productId)); } return productData; @@ -115,10 +129,15 @@ public WorkingCapitalLoanProductData retrieveNewWorkingCapitalLoanProductDetails final Collection delinquencyBucketOptions = this.delinquencyReadPlatformService .retrieveAllDelinquencyBuckets(); final List nearBreachOptions = nearBreachReadPlatformService.retrieveAll(); + final List paymentTypeOptions = this.paymentTypeReadService.retrieveAllPaymentTypes(); final List accountingRuleOptions = WorkingCapitalAccountingRuleType.toStringEnumOptions(); final Map> accountingMappingOptions = this.accountingDropdownReadPlatformService .retrieveAccountMappingOptionsForLoanProducts(); + final List chargeOffReasonOptions = this.codeValueReadPlatformService + .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.CHARGE_OFF_REASONS); + final List writeOffReasonOptions = this.codeValueReadPlatformService + .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.WRITE_OFF_REASONS); return WorkingCapitalLoanProductData.builder() // .fundOptions(fundOptions) // @@ -132,9 +151,15 @@ public WorkingCapitalLoanProductData retrieveNewWorkingCapitalLoanProductDetails .delinquencyMinimumPaymentTypeOptions(delinquencyMinimumPaymentTypeOptions) // .delinquencyBucketOptions( delinquencyBucketOptions != null && !delinquencyBucketOptions.isEmpty() ? delinquencyBucketOptions : null) // + .paymentTypeOptions(paymentTypeOptions != null && !paymentTypeOptions.isEmpty() ? paymentTypeOptions : null) // + // TODO: Populate WC-specific charge options when WC charges are introduced. + .chargeOptions(List.of()) // + .penaltyOptions(List.of()) // .accountingRuleOptions(accountingRuleOptions) // .accountingMappingOptions(accountingMappingOptions) // .nearBreachOptions(nearBreachOptions) // + .chargeOffReasonOptions(chargeOffReasonOptions != null && !chargeOffReasonOptions.isEmpty() ? chargeOffReasonOptions : null) // + .writeOffReasonOptions(writeOffReasonOptions != null && !writeOffReasonOptions.isEmpty() ? writeOffReasonOptions : null) // .build(); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductToGLAccountMappingHelper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductToGLAccountMappingHelper.java index 8eb8980440d..b7bec1c9cad 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductToGLAccountMappingHelper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductToGLAccountMappingHelper.java @@ -32,6 +32,8 @@ import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; import org.apache.fineract.accounting.producttoaccountmapping.exception.ProductToGLAccountMappingInvalidException; +import org.apache.fineract.accounting.producttoaccountmapping.service.ProductToGLAccountMappingHelper; +import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.portfolio.PortfolioProductType; import org.springframework.stereotype.Component; @@ -46,6 +48,7 @@ public class WorkingCapitalLoanProductToGLAccountMappingHelper { private final ProductToGLAccountMappingRepository accountMappingRepository; private final GLAccountRepositoryWrapper accountRepositoryWrapper; private final FromJsonHelper fromApiJsonHelper; + private final ProductToGLAccountMappingHelper productToGLAccountMappingHelper; public void saveCashBasedAccountMapping(final JsonElement element, final Long productId) { // assets / liabilities (fund source can be either asset or liability) @@ -177,6 +180,33 @@ public Map populateChangesForNewCashBasedMappingCreation(final J return changes; } + public void saveAdvancedMappings(final JsonCommand command, final JsonElement element, final Long productId) { + this.productToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, productId, null, PRODUCT_TYPE); + this.productToGLAccountMappingHelper.saveChargesToGLAccountMappings(command, element, productId, null, PRODUCT_TYPE, true); + this.productToGLAccountMappingHelper.saveChargesToGLAccountMappings(command, element, productId, null, PRODUCT_TYPE, false); + this.productToGLAccountMappingHelper.saveReasonToGLAccountMappings(command, element, productId, null, PRODUCT_TYPE, + LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS, + LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID, CashAccountsForLoan.CHARGE_OFF_EXPENSE); + this.productToGLAccountMappingHelper.saveReasonToGLAccountMappings(command, element, productId, null, PRODUCT_TYPE, + LoanProductAccountingParams.WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS, + LoanProductAccountingParams.WRITE_OFF_REASON_CODE_VALUE_ID, CashAccountsForLoan.LOSSES_WRITTEN_OFF); + } + + public void updateAdvancedMappings(final JsonCommand command, final JsonElement element, final Long productId, + final Map changes) { + this.productToGLAccountMappingHelper.updatePaymentChannelToFundSourceMappings(command, element, productId, changes, PRODUCT_TYPE); + this.productToGLAccountMappingHelper.updateChargeToIncomeAccountMappings(command, element, productId, changes, PRODUCT_TYPE, true); + this.productToGLAccountMappingHelper.updateChargeToIncomeAccountMappings(command, element, productId, changes, PRODUCT_TYPE, false); + this.productToGLAccountMappingHelper.updateReasonToGLAccountMappings(command, element, productId, changes, PRODUCT_TYPE, + this.accountMappingRepository.findAllChargeOffReasonsMappings(productId, PRODUCT_TYPE.getValue()), + LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS, + LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID, CashAccountsForLoan.CHARGE_OFF_EXPENSE); + this.productToGLAccountMappingHelper.updateReasonToGLAccountMappings(command, element, productId, changes, PRODUCT_TYPE, + this.accountMappingRepository.findAllWriteOffReasonsMappings(productId, PRODUCT_TYPE.getValue()), + LoanProductAccountingParams.WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS, + LoanProductAccountingParams.WRITE_OFF_REASON_CODE_VALUE_ID, CashAccountsForLoan.LOSSES_WRITTEN_OFF); + } + private void putChange(final Map changes, final JsonElement element, final LoanProductAccountingParams param) { changes.put(param.getValue(), this.fromApiJsonHelper.extractLongNamed(param.getValue(), element)); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalProductAccountingMappingServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalProductAccountingMappingServiceImpl.java index 2ef2a81ff4e..20f84158fdd 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalProductAccountingMappingServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalProductAccountingMappingServiceImpl.java @@ -18,18 +18,23 @@ */ package org.apache.fineract.portfolio.workingcapitalloanproduct.service; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants; import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan; import org.apache.fineract.accounting.common.AccountingConstants.LoanProductAccountingDataParams; import org.apache.fineract.accounting.glaccount.data.GLAccountData; import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMappingRepository; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAccountingRuleType; @@ -44,6 +49,25 @@ public class WorkingCapitalProductAccountingMappingServiceImpl implements Workin private final ProductToGLAccountMappingRepository accountMappingRepository; private final FromJsonHelper fromApiJsonHelper; + private void validateCreateAccountMapping(final JsonElement element, String arrayName, String key) { + final JsonArray array = this.fromApiJsonHelper.extractJsonArrayNamed(arrayName, element); + if (array != null) { + ArrayList values = new ArrayList<>(array.size()); + for (int i = 0; i < array.size(); i++) { + final JsonObject jsonObject = array.get(i).getAsJsonObject(); + if (jsonObject.get(key) != null) { + String value = jsonObject.get(key).getAsString(); + if (!values.contains(value)) { + values.add(value); + } else { + String e = arrayName + "." + key; + throw new PlatformApiDataValidationException("duplicated.enrty.for." + e, "Duplicated entry for " + e, e); + } + } + } + } + } + @Override @Transactional public void createAccountMapping(final Long wcLoanProductId, final JsonCommand command) { @@ -51,8 +75,23 @@ public void createAccountMapping(final Long wcLoanProductId, final JsonCommand c final String accountingRuleValue = this.fromApiJsonHelper.extractStringNamed("accountingRule", element); final WorkingCapitalAccountingRuleType accountingRuleType = WorkingCapitalAccountingRuleType.valueOf(accountingRuleValue); + validateCreateAccountMapping(element, + AccountingConstants.LoanProductAccountingParams.PAYMENT_CHANNEL_FUND_SOURCE_MAPPING.getValue(), + AccountingConstants.LoanProductAccountingParams.PAYMENT_TYPE.getValue()); + validateCreateAccountMapping(element, AccountingConstants.LoanProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(), + AccountingConstants.LoanProductAccountingParams.CHARGE_ID.getValue()); + validateCreateAccountMapping(element, AccountingConstants.LoanProductAccountingParams.FEE_INCOME_ACCOUNT_MAPPING.getValue(), + AccountingConstants.LoanProductAccountingParams.CHARGE_ID.getValue()); + validateCreateAccountMapping(element, + AccountingConstants.LoanProductAccountingParams.CHARGE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue(), + AccountingConstants.LoanProductAccountingParams.CHARGE_OFF_REASON_CODE_VALUE_ID.getValue()); + validateCreateAccountMapping(element, + AccountingConstants.LoanProductAccountingParams.WRITE_OFF_REASON_TO_EXPENSE_ACCOUNT_MAPPINGS.getValue(), + AccountingConstants.LoanProductAccountingParams.WRITE_OFF_REASON_CODE_VALUE_ID.getValue()); + if (accountingRuleType.isCashBased()) { this.mappingHelper.saveCashBasedAccountMapping(element, wcLoanProductId); + this.mappingHelper.saveAdvancedMappings(command, element, wcLoanProductId); } } @@ -72,6 +111,7 @@ public Map updateAccountMapping(final Long wcLoanProductId, fina } else { if (accountingRuleType.isCashBased()) { this.mappingHelper.handleChangesToCashBasedAccountMapping(wcLoanProductId, changes, element); + this.mappingHelper.updateAdvancedMappings(command, element, wcLoanProductId, changes); } } return changes;