From 22c4403a14668a8aff664cdaa63ff8fea26c87b3 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Tue, 29 Sep 2026 17:56:50 +0530 Subject: [PATCH 1/4] feat(loan-product): add advance loan product ext entity, API and liquibase --- .../loanproduct/data/LoanProductData.java | 14 + .../domain/LoanProductAdvanceExt.java | 302 ++++++++++++++ .../LoanProductAdvanceExtRepository.java | 16 + .../api/LoanProductsApiResource.java | 8 +- .../LoanProductDataValidator.java | 8 + .../service/LoanProductAdvanceExtService.java | 373 ++++++++++++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 11 + .../starter/LoanProductConfiguration.java | 6 +- .../db/changelog/tenant/changelog-tenant.xml | 2 + .../0571_create_loan_product_advance_ext.xml | 112 ++++++ ...72_add_loan_product_advance_ext_fields.xml | 71 ++++ 11 files changed, 920 insertions(+), 3 deletions(-) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductAdvanceExt.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductAdvanceExtRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductAdvanceExtService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_loan_product_advance_ext.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_loan_product_advance_ext_fields.xml diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java index 94f0f5bea..a68c170bb 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java @@ -225,6 +225,12 @@ public class LoanProductData implements Serializable { private final LoanProductGuaranteeData productGuaranteeData; private final Boolean accountMovesOutOfNPAOnlyOnArrearsCompletion; private LoanProductConfigurableAttributes allowAttributeOverrides; + + /** + * "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book), flat by their form field + * name. Set after construction (like {@link #allowAttributeOverrides}) so the edit form can repopulate. + */ + private Map advanceExtensions; private final boolean syncExpectedWithDisbursementDate; private final boolean isEqualAmortization; private final BigDecimal fixedPrincipalPercentagePerInstallment; @@ -1466,6 +1472,14 @@ public class LoanProductData implements Serializable { this.allowAttributeOverrides = loanProductConfigurableAttributes; } + public void setAdvanceExtensions(final Map advanceExtensions) { + this.advanceExtensions = advanceExtensions; + } + + public Map getAdvanceExtensions() { + return this.advanceExtensions; + } + public boolean isIsLinkedToFloatingInterestRates() { return this.isLinkedToFloatingInterestRates; } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductAdvanceExt.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductAdvanceExt.java new file mode 100644 index 000000000..03f96300e --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductAdvanceExt.java @@ -0,0 +1,302 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * "Advances A/C" product configuration that Fineract's loan-product schema has no home for + * (security & margin, lien, deposit-linked interest, penal/NPA handling, renewal, monitoring, + * cheque book). It is a standalone satellite keyed by {@code loan_product_id} rather than a widening + * of {@code m_product_loan} or a new relation on {@link LoanProduct}: the product entity graph is + * loaded on the hot loan-origination path, so it is deliberately left untouched. + * + * Typed columns (not a JSON blob) so reports can filter on them and GL ids get a real FK. + */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "m_product_loan_advance_ext") +public class LoanProductAdvanceExt { + + @Id + @Column(name = "loan_product_id") + private Long loanProductId; + + /** Shared primary key, mapped read-only; {@link #loanProductId} is the writable side. */ + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "loan_product_id", insertable = false, updatable = false) + private LoanProduct loanProduct; + + // ---- classification ------------------------------------------------- + @Column(name = "product_type_group") + private String productTypeGroup; + + @Column(name = "product_type_sub_group") + private String productTypeSubGroup; + + @Column(name = "gl_account_id") + private Long glAccountId; + + @Column(name = "loan_product_category") + private String loanProductCategory; + + @Column(name = "priority_sector") + private String prioritySector; + + @Column(name = "cibil_account_type") + private String cibilAccountType; + + @Column(name = "kfs_enabled") + private Boolean kfsEnabled; + + @Column(name = "apr_enabled") + private Boolean aprEnabled; + + // ---- facility ------------------------------------------------------- + @Column(name = "facility_type") + private String facilityType; + + // ---- security & margin ---------------------------------------------- + @Column(name = "security_type") + private String securityType; + + @Column(name = "charge_type") + private String chargeType; + + @Column(name = "eligible_amount_basis") + private String eligibleAmountBasis; + + @Column(name = "margin_pct") + private BigDecimal marginPct; + + @Column(name = "ltv_pct") + private BigDecimal ltvPct; + + @Column(name = "dp_method") + private String dpMethod; + + // ---- lien ----------------------------------------------------------- + @Column(name = "lien_required") + private Boolean lienRequired; + + @Column(name = "lien_marking_stage") + private String lienMarkingStage; + + @Column(name = "lien_certificate_required") + private Boolean lienCertificateRequired; + + @Column(name = "lien_certificate_prefix") + private String lienCertificatePrefix; + + @Column(name = "print_certificate_on_disbursement") + private Boolean printCertificateOnDisbursement; + + // ---- interest (extras; rate bounds live on the core product) -------- + @Column(name = "interest_basis") + private String interestBasis; + + @Column(name = "spread_pct") + private BigDecimal spreadPct; + + @Column(name = "interest_charged_on") + private String interestChargedOn; + + @Column(name = "interest_posting_frequency") + private String interestPostingFrequency; + + // ---- penal & NPA (extras; npaDays/grace/tolerance live on core) ------ + @Column(name = "penal_applicable") + private Boolean penalApplicable; + + @Column(name = "penal_rate_pct") + private BigDecimal penalRatePct; + + @Column(name = "penal_applied_on") + private String penalAppliedOn; + + @Column(name = "penal_grace_days") + private Integer penalGraceDays; + + @Column(name = "npa_trigger") + private String npaTrigger; + + @Column(name = "interest_after_npa") + private String interestAfterNpa; + + @Column(name = "undebited_interest_gl_id") + private Long undebitedInterestGlId; + + // ---- renewal & closure ---------------------------------------------- + @Column(name = "renewal_allowed") + private Boolean renewalAllowed; + + @Column(name = "renewal_period_months") + private Integer renewalPeriodMonths; + + @Column(name = "renewal_grace_days") + private Integer renewalGraceDays; + + @Column(name = "preclosure_allowed") + private String preclosureAllowed; + + @Column(name = "lock_in_months") + private Integer lockInMonths; + + @Column(name = "interest_on_preclosure") + private String interestOnPreclosure; + + // ---- monitoring ----------------------------------------------------- + @Column(name = "revaluation_freq_months") + private Integer revaluationFreqMonths; + + @Column(name = "insurance_required") + private Boolean insuranceRequired; + + @Column(name = "stock_statement_freq_months") + private Integer stockStatementFreqMonths; + + // ---- cheque book ---------------------------------------------------- + @Column(name = "cheque_book_enabled") + private Boolean chequeBookEnabled; + + @Column(name = "cheque_type") + private String chequeType; + + @Column(name = "default_cheque_book_size") + private Integer defaultChequeBookSize; + + @Column(name = "pps_threshold") + private BigDecimal ppsThreshold; + + @Column(name = "pps_mandatory_above") + private BigDecimal ppsMandatoryAbove; + + // ---- share (co-op) -------------------------------------------------- + @Column(name = "share_capital_gl_id") + private Long shareCapitalGlId; + + // ---- security & lien extras ----------------------------------------- + /** Pledgeable deposit product names, joined with "|". */ + @Column(name = "eligible_deposit_products") + private String eligibleDepositProducts; + + @Column(name = "lien_tenure_not_exceed_maturity") + private Boolean lienTenureNotExceedMaturity; + + @Column(name = "lien_auto_release_on_closure") + private Boolean lienAutoReleaseOnClosure; + + @Column(name = "lien_allow_multiple_deposits") + private Boolean lienAllowMultipleDeposits; + + @Column(name = "lien_adjust_from_maturity") + private Boolean lienAdjustFromMaturity; + + @Column(name = "lien_allow_third_party") + private Boolean lienAllowThirdParty; + + @Column(name = "lien_allow_renewal_under_lien") + private Boolean lienAllowRenewalUnderLien; + + // ---- limit & tenure extras ------------------------------------------ + @Column(name = "repayment_type") + private String repaymentType; + + @Column(name = "appropriation_order") + private String appropriationOrder; + + @Column(name = "debit_balance_allow") + private Boolean debitBalanceAllow; + + @Column(name = "allow_debit_after_due_date") + private Boolean allowDebitAfterDueDate; + + @Column(name = "branch_level_account_type") + private Boolean branchLevelAccountType; + + // ---- interest extras ------------------------------------------------ + @Column(name = "interest_calc_method") + private String interestCalcMethod; + + @Column(name = "interest_on_credit_balance") + private Boolean interestOnCreditBalance; + + @Column(name = "interest_posting_day") + private String interestPostingDay; + + // ---- NPA upgrade rules ---------------------------------------------- + @Column(name = "npa_upgrade_only_all_arrears") + private Boolean npaUpgradeOnlyAllArrears; + + @Column(name = "npa_keep_standard_if_deposit_covers") + private Boolean npaKeepStandardIfDepositCovers; + + // ---- cheque book full config ---------------------------------------- + /** Allowed book sizes (leaves), joined with "|". */ + @Column(name = "cheque_book_sizes") + private String chequeBookSizes; + + @Column(name = "cheque_max_books_outstanding") + private Integer chequeMaxBooksOutstanding; + + @Column(name = "cheque_reissue_when_leaves") + private Integer chequeReissueWhenLeaves; + + @Column(name = "cheque_first_book_free") + private Boolean chequeFirstBookFree; + + @Column(name = "cheque_free_books_per_year") + private Integer chequeFreeBooksPerYear; + + @Column(name = "cheque_name_printed") + private String chequeNamePrinted; + + @Column(name = "cheque_print_description") + private String chequePrintDescription; + + @Column(name = "cheque_stop_payment_allowed") + private Boolean chequeStopPaymentAllowed; + + @Column(name = "cheque_maintain_stock_register") + private Boolean chequeMaintainStockRegister; + + @Column(name = "cheque_allow_withdrawal_slip") + private Boolean chequeAllowWithdrawalSlip; + + @Column(name = "cheque_block_npa") + private Boolean chequeBlockNpa; + + @Column(name = "cheque_block_limit_expired") + private Boolean chequeBlockLimitExpired; + + @Column(name = "cheque_block_over_dp") + private Boolean chequeBlockOverDp; + + @Column(name = "cheque_block_frozen") + private Boolean chequeBlockFrozen; + + @Column(name = "cheque_block_lien_released") + private Boolean chequeBlockLienReleased; + + public LoanProductAdvanceExt(final LoanProduct loanProduct) { + this.loanProduct = loanProduct; + this.loanProductId = loanProduct.getId(); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductAdvanceExtRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductAdvanceExtRepository.java new file mode 100644 index 000000000..70ba4ccfb --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductAdvanceExtRepository.java @@ -0,0 +1,16 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LoanProductAdvanceExtRepository extends JpaRepository { + + Optional findByLoanProductId(Long loanProductId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java index bd3f87c43..c4ec760a5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java @@ -107,6 +107,7 @@ import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundEx import org.apache.fineract.portfolio.loanproduct.productmix.data.ProductMixData; import org.apache.fineract.portfolio.loanproduct.productmix.service.ProductMixReadPlatformService; import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService; +import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService; import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService; import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; @@ -142,7 +143,8 @@ public class LoanProductsApiResource { LoanProductConstants.CAPITALIZED_INCOME_CALCULATION_TYPE_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_STRATEGY_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_TYPE_PARAM_NAME, LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_CALCULATION_TYPE_PARAM_NAME, - LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME)); + LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME, + "advanceExtensions")); private static final Set PRODUCT_MIX_DATA_PARAMETERS = new HashSet<>( Arrays.asList("productId", "productName", "restrictedProducts", "allowedProducts", "productOptions")); @@ -153,6 +155,7 @@ public class LoanProductsApiResource { private final PlatformSecurityContext context; private final LoanProductReadPlatformService loanProductReadPlatformService; + private final LoanProductAdvanceExtService loanProductAdvanceExtService; private final ChargeReadPlatformService chargeReadPlatformService; private final CurrencyReadPlatformService currencyReadPlatformService; private final FundReadPlatformService fundReadPlatformService; @@ -384,6 +387,9 @@ public class LoanProductsApiResource { if (settings.isTemplate()) { loanProduct = handleTemplate(loanProduct); } + // "Advances A/C" satellite fields, so the edit form can repopulate. Set last: the accounting and template + // steps above return fresh LoanProductData copies that would otherwise drop it. Empty map for a standard product. + loanProduct.setAdvanceExtensions(this.loanProductAdvanceExtService.retrieveAsMap(productId)); return this.toApiJsonSerializer.serialize(settings, loanProduct, LOAN_PRODUCT_DATA_PARAMETERS); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java index 0f06e0343..57a683d1d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java @@ -72,6 +72,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.InterestRecalculationCom import org.apache.fineract.portfolio.loanproduct.domain.LoanPreCloseInterestCalculationStrategy; import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule; +import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductValueConditionType; import org.apache.fineract.portfolio.loanproduct.domain.LoanRescheduleStrategyMethod; import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes; @@ -212,6 +213,13 @@ public final class LoanProductDataValidator { LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME // )); + static { + // "Advances A/C" satellite params are persisted by LoanProductAdvanceExtService, which owns the + // authoritative list of their names — added here rather than restated so the two cannot drift, and so + // the unsupported-parameter check keeps rejecting genuine typos. + SUPPORTED_PARAMETERS.addAll(Arrays.asList(LoanProductAdvanceExtService.ADVANCE_PARAMETERS)); + } + private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName, LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName, LoanProductConstants.interestCalculationPeriodTypeParamName, LoanProductConstants.inArrearsToleranceParamName, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductAdvanceExtService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductAdvanceExtService.java new file mode 100644 index 000000000..9308653d1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductAdvanceExtService.java @@ -0,0 +1,373 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.service; + +import com.google.gson.JsonElement; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository; +import org.springframework.stereotype.Service; + +/** + * Persists and reads the "Advances A/C" satellite ({@link LoanProductAdvanceExt}). + * + * Only touched for an Advances product: a standard loan product carries none of {@link #ADVANCE_PARAMETERS}, + * so {@link #saveOrUpdate} early-returns and never writes an empty row. Called from inside the loan-product + * create/update transaction, so a failure here rolls the whole product back. + */ +@Service +@RequiredArgsConstructor +public class LoanProductAdvanceExtService { + + private final LoanProductAdvanceExtRepository repository; + + /** + * Every Advance parameter name. Shared with {@code LoanProductDataValidator} (added to its + * SUPPORTED_PARAMETERS) so the two can never disagree about what is accepted. These are the + * satellite-only fields; core fields (principal, rate, tenure, npaDays, delinquencyBucketId, + * graceOnArrearsAgeing, inArrearsTolerance, accountingRule...) are handled by the standard product. + */ + public static final String[] ADVANCE_PARAMETERS = { "productTypeGroup", "productTypeSubGroup", "glAccount", + "loanProductCategory", "prioritySector", "cibilAccountType", "kfsEnabled", "aprEnabled", "facilityType", + "securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired", + "lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement", + "interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate", + "penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId", + "renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths", + "interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths", + "chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove", + "shareCapitalGl", + // security & lien extras + "eligibleDepositProducts", "lienTenureNotExceedMaturity", "lienAutoReleaseOnClosure", + "lienAllowMultipleDeposits", "lienAdjustFromMaturity", "lienAllowThirdParty", "lienAllowRenewalUnderLien", + // limit & tenure extras + "repaymentType", "appropriationOrder", "debitBalanceAllow", "allowDebitAfterDueDate", "branchLevelAccountType", + // interest extras + "interestCalcMethod", "interestOnCreditBalance", "interestPostingDay", + // NPA upgrade rules + "npaUpgradeOnlyAllArrears", "npaKeepStandardIfDepositCovers", + // cheque book full config + "chequeBookSizes", "chequeMaxBooksOutstanding", "chequeReissueWhenLeaves", "chequeFirstBookFree", + "chequeFreeBooksPerYear", "chequeNamePrinted", "chequePrintDescription", "chequeStopPaymentAllowed", + "chequeMaintainStockRegister", "chequeAllowWithdrawalSlip", "chequeBlockNpa", "chequeBlockLimitExpired", + "chequeBlockOverDp", "chequeBlockFrozen", "chequeBlockLienReleased" }; + + /** + * Insert or partial-update the satellite for this product. Returns the fields that actually changed so the + * caller can fold them into the loan-product audit {@code changes} map. + */ + public Map saveOrUpdate(final LoanProduct loanProduct, final JsonCommand command) { + final Map changes = new LinkedHashMap<>(); + if (!hasAnyAdvanceParameter(command)) { + return changes; + } + final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId()) + .orElseGet(() -> new LoanProductAdvanceExt(loanProduct)); + applyScalarFields(ext, command, changes); + this.repository.save(ext); + return changes; + } + + public Map retrieveAsMap(final Long loanProductId) { + return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap) + .orElseGet(LinkedHashMap::new); + } + + private boolean hasAnyAdvanceParameter(final JsonCommand command) { + for (final String param : ADVANCE_PARAMETERS) { + if (command.parameterExists(param)) { + return true; + } + } + return false; + } + + private void applyScalarFields(final LoanProductAdvanceExt ext, final JsonCommand command, + final Map changes) { + setString(command, "productTypeGroup", ext::getProductTypeGroup, ext::setProductTypeGroup, changes); + setString(command, "productTypeSubGroup", ext::getProductTypeSubGroup, ext::setProductTypeSubGroup, changes); + setLong(command, "glAccount", ext::getGlAccountId, ext::setGlAccountId, changes); + setString(command, "loanProductCategory", ext::getLoanProductCategory, ext::setLoanProductCategory, changes); + setString(command, "prioritySector", ext::getPrioritySector, ext::setPrioritySector, changes); + setString(command, "cibilAccountType", ext::getCibilAccountType, ext::setCibilAccountType, changes); + setBoolean(command, "kfsEnabled", ext::getKfsEnabled, ext::setKfsEnabled, changes); + setBoolean(command, "aprEnabled", ext::getAprEnabled, ext::setAprEnabled, changes); + setString(command, "facilityType", ext::getFacilityType, ext::setFacilityType, changes); + setString(command, "securityType", ext::getSecurityType, ext::setSecurityType, changes); + setString(command, "chargeType", ext::getChargeType, ext::setChargeType, changes); + setString(command, "eligibleAmountBasis", ext::getEligibleAmountBasis, ext::setEligibleAmountBasis, changes); + setDecimal(command, "margin", ext::getMarginPct, ext::setMarginPct, changes); + setDecimal(command, "ltv", ext::getLtvPct, ext::setLtvPct, changes); + setString(command, "dpMethod", ext::getDpMethod, ext::setDpMethod, changes); + setBoolean(command, "lienRequired", ext::getLienRequired, ext::setLienRequired, changes); + setString(command, "lienMarkingStage", ext::getLienMarkingStage, ext::setLienMarkingStage, changes); + setBoolean(command, "lienCertificateRequired", ext::getLienCertificateRequired, ext::setLienCertificateRequired, + changes); + setString(command, "lienCertificatePrefix", ext::getLienCertificatePrefix, ext::setLienCertificatePrefix, + changes); + setBoolean(command, "printCertificateOnDisbursement", ext::getPrintCertificateOnDisbursement, + ext::setPrintCertificateOnDisbursement, changes); + setString(command, "interestBasis", ext::getInterestBasis, ext::setInterestBasis, changes); + setDecimal(command, "spread", ext::getSpreadPct, ext::setSpreadPct, changes); + setString(command, "interestChargedOn", ext::getInterestChargedOn, ext::setInterestChargedOn, changes); + setString(command, "interestPostingFrequency", ext::getInterestPostingFrequency, + ext::setInterestPostingFrequency, changes); + setBoolean(command, "penalApplicable", ext::getPenalApplicable, ext::setPenalApplicable, changes); + setDecimal(command, "penalRate", ext::getPenalRatePct, ext::setPenalRatePct, changes); + setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes); + setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes); + setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes); + setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes); + setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes); + setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes); + setInteger(command, "renewalPeriodMonths", ext::getRenewalPeriodMonths, ext::setRenewalPeriodMonths, changes); + setInteger(command, "renewalGraceDays", ext::getRenewalGraceDays, ext::setRenewalGraceDays, changes); + setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes); + setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes); + setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes); + setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths, + changes); + setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes); + setInteger(command, "stockStatementFreqMonths", ext::getStockStatementFreqMonths, + ext::setStockStatementFreqMonths, changes); + setBoolean(command, "chequeBookEnabled", ext::getChequeBookEnabled, ext::setChequeBookEnabled, changes); + setString(command, "chequeType", ext::getChequeType, ext::setChequeType, changes); + setInteger(command, "defaultChequeBookSize", ext::getDefaultChequeBookSize, ext::setDefaultChequeBookSize, + changes); + setDecimal(command, "ppsThreshold", ext::getPpsThreshold, ext::setPpsThreshold, changes); + setDecimal(command, "ppsMandatoryAbove", ext::getPpsMandatoryAbove, ext::setPpsMandatoryAbove, changes); + setLong(command, "shareCapitalGl", ext::getShareCapitalGlId, ext::setShareCapitalGlId, changes); + // security & lien extras + setString(command, "eligibleDepositProducts", ext::getEligibleDepositProducts, ext::setEligibleDepositProducts, + changes); + setBoolean(command, "lienTenureNotExceedMaturity", ext::getLienTenureNotExceedMaturity, + ext::setLienTenureNotExceedMaturity, changes); + setBoolean(command, "lienAutoReleaseOnClosure", ext::getLienAutoReleaseOnClosure, + ext::setLienAutoReleaseOnClosure, changes); + setBoolean(command, "lienAllowMultipleDeposits", ext::getLienAllowMultipleDeposits, + ext::setLienAllowMultipleDeposits, changes); + setBoolean(command, "lienAdjustFromMaturity", ext::getLienAdjustFromMaturity, ext::setLienAdjustFromMaturity, + changes); + setBoolean(command, "lienAllowThirdParty", ext::getLienAllowThirdParty, ext::setLienAllowThirdParty, changes); + setBoolean(command, "lienAllowRenewalUnderLien", ext::getLienAllowRenewalUnderLien, + ext::setLienAllowRenewalUnderLien, changes); + // limit & tenure extras + setString(command, "repaymentType", ext::getRepaymentType, ext::setRepaymentType, changes); + setString(command, "appropriationOrder", ext::getAppropriationOrder, ext::setAppropriationOrder, changes); + setBoolean(command, "debitBalanceAllow", ext::getDebitBalanceAllow, ext::setDebitBalanceAllow, changes); + setBoolean(command, "allowDebitAfterDueDate", ext::getAllowDebitAfterDueDate, ext::setAllowDebitAfterDueDate, + changes); + setBoolean(command, "branchLevelAccountType", ext::getBranchLevelAccountType, ext::setBranchLevelAccountType, + changes); + // interest extras + setString(command, "interestCalcMethod", ext::getInterestCalcMethod, ext::setInterestCalcMethod, changes); + setBoolean(command, "interestOnCreditBalance", ext::getInterestOnCreditBalance, ext::setInterestOnCreditBalance, + changes); + setString(command, "interestPostingDay", ext::getInterestPostingDay, ext::setInterestPostingDay, changes); + // NPA upgrade rules + setBoolean(command, "npaUpgradeOnlyAllArrears", ext::getNpaUpgradeOnlyAllArrears, + ext::setNpaUpgradeOnlyAllArrears, changes); + setBoolean(command, "npaKeepStandardIfDepositCovers", ext::getNpaKeepStandardIfDepositCovers, + ext::setNpaKeepStandardIfDepositCovers, changes); + // cheque book full config + setString(command, "chequeBookSizes", ext::getChequeBookSizes, ext::setChequeBookSizes, changes); + setInteger(command, "chequeMaxBooksOutstanding", ext::getChequeMaxBooksOutstanding, + ext::setChequeMaxBooksOutstanding, changes); + setInteger(command, "chequeReissueWhenLeaves", ext::getChequeReissueWhenLeaves, ext::setChequeReissueWhenLeaves, + changes); + setBoolean(command, "chequeFirstBookFree", ext::getChequeFirstBookFree, ext::setChequeFirstBookFree, changes); + setInteger(command, "chequeFreeBooksPerYear", ext::getChequeFreeBooksPerYear, ext::setChequeFreeBooksPerYear, + changes); + setString(command, "chequeNamePrinted", ext::getChequeNamePrinted, ext::setChequeNamePrinted, changes); + setString(command, "chequePrintDescription", ext::getChequePrintDescription, ext::setChequePrintDescription, + changes); + setBoolean(command, "chequeStopPaymentAllowed", ext::getChequeStopPaymentAllowed, + ext::setChequeStopPaymentAllowed, changes); + setBoolean(command, "chequeMaintainStockRegister", ext::getChequeMaintainStockRegister, + ext::setChequeMaintainStockRegister, changes); + setBoolean(command, "chequeAllowWithdrawalSlip", ext::getChequeAllowWithdrawalSlip, + ext::setChequeAllowWithdrawalSlip, changes); + setBoolean(command, "chequeBlockNpa", ext::getChequeBlockNpa, ext::setChequeBlockNpa, changes); + setBoolean(command, "chequeBlockLimitExpired", ext::getChequeBlockLimitExpired, ext::setChequeBlockLimitExpired, + changes); + setBoolean(command, "chequeBlockOverDp", ext::getChequeBlockOverDp, ext::setChequeBlockOverDp, changes); + setBoolean(command, "chequeBlockFrozen", ext::getChequeBlockFrozen, ext::setChequeBlockFrozen, changes); + setBoolean(command, "chequeBlockLienReleased", ext::getChequeBlockLienReleased, ext::setChequeBlockLienReleased, + changes); + } + + // ---- typed setters: only write params that are present, and only report real changes ---------- + + private interface Getter { + + T get(); + } + + private interface Ser { + + void set(T value); + } + + private void putIfChanged(final String param, final T oldValue, final T newValue, final Ser setter, + final Map changes) { + if (differs(oldValue, newValue)) { + setter.set(newValue); + changes.put(param, newValue); + } + } + + private void setString(final JsonCommand command, final String param, final Getter getter, + final Ser setter, final Map changes) { + if (command.parameterExists(param)) { + putIfChanged(param, getter.get(), command.stringValueOfParameterNamedAllowingNull(param), setter, changes); + } + } + + private void setLong(final JsonCommand command, final String param, final Getter getter, + final Ser setter, final Map changes) { + if (command.parameterExists(param)) { + putIfChanged(param, getter.get(), command.longValueOfParameterNamed(param), setter, changes); + } + } + + private void setInteger(final JsonCommand command, final String param, final Getter getter, + final Ser setter, final Map changes) { + if (command.parameterExists(param)) { + putIfChanged(param, getter.get(), command.integerValueOfParameterNamed(param), setter, changes); + } + } + + private void setDecimal(final JsonCommand command, final String param, final Getter getter, + final Ser setter, final Map changes) { + if (command.parameterExists(param)) { + putIfChanged(param, getter.get(), command.bigDecimalValueOfParameterNamed(param), setter, changes); + } + } + + /** + * The form stores its yes/no dropdowns as the strings "YES"/"NO" and its checkboxes as real booleans; + * accept both shapes so a "NO" is not silently read as null. + */ + private void setBoolean(final JsonCommand command, final String param, final Getter getter, + final Ser setter, final Map changes) { + if (command.parameterExists(param)) { + putIfChanged(param, getter.get(), asBoolean(command.jsonElement(param)), setter, changes); + } + } + + private static Boolean asBoolean(final JsonElement element) { + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) { + return null; + } + if (element.getAsJsonPrimitive().isBoolean()) { + return element.getAsBoolean(); + } + final String raw = element.getAsString(); + if (raw == null || raw.isBlank()) { + return null; + } + return "true".equalsIgnoreCase(raw) || "yes".equalsIgnoreCase(raw) || "1".equals(raw); + } + + /** BigDecimal.equals compares scale, so 2.0 vs 2.00 would read as a change; compareTo does not. */ + private static boolean differs(final Object oldValue, final Object newValue) { + if (oldValue instanceof BigDecimal a && newValue instanceof BigDecimal b) { + return a.compareTo(b) != 0; + } + return !Objects.equals(oldValue, newValue); + } + + private static Map toMap(final LoanProductAdvanceExt ext) { + final Map map = new LinkedHashMap<>(); + map.put("productTypeGroup", ext.getProductTypeGroup()); + map.put("productTypeSubGroup", ext.getProductTypeSubGroup()); + map.put("glAccount", ext.getGlAccountId()); + map.put("loanProductCategory", ext.getLoanProductCategory()); + map.put("prioritySector", ext.getPrioritySector()); + map.put("cibilAccountType", ext.getCibilAccountType()); + map.put("kfsEnabled", ext.getKfsEnabled()); + map.put("aprEnabled", ext.getAprEnabled()); + map.put("facilityType", ext.getFacilityType()); + map.put("securityType", ext.getSecurityType()); + map.put("chargeType", ext.getChargeType()); + map.put("eligibleAmountBasis", ext.getEligibleAmountBasis()); + map.put("margin", ext.getMarginPct()); + map.put("ltv", ext.getLtvPct()); + map.put("dpMethod", ext.getDpMethod()); + map.put("lienRequired", ext.getLienRequired()); + map.put("lienMarkingStage", ext.getLienMarkingStage()); + map.put("lienCertificateRequired", ext.getLienCertificateRequired()); + map.put("lienCertificatePrefix", ext.getLienCertificatePrefix()); + map.put("printCertificateOnDisbursement", ext.getPrintCertificateOnDisbursement()); + map.put("interestBasis", ext.getInterestBasis()); + map.put("spread", ext.getSpreadPct()); + map.put("interestChargedOn", ext.getInterestChargedOn()); + map.put("interestPostingFrequency", ext.getInterestPostingFrequency()); + map.put("penalApplicable", ext.getPenalApplicable()); + map.put("penalRate", ext.getPenalRatePct()); + map.put("penalAppliedOn", ext.getPenalAppliedOn()); + map.put("penalGraceDays", ext.getPenalGraceDays()); + map.put("npaTrigger", ext.getNpaTrigger()); + map.put("interestAfterNpa", ext.getInterestAfterNpa()); + map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId()); + map.put("renewalAllowed", ext.getRenewalAllowed()); + map.put("renewalPeriodMonths", ext.getRenewalPeriodMonths()); + map.put("renewalGraceDays", ext.getRenewalGraceDays()); + map.put("preclosureAllowed", ext.getPreclosureAllowed()); + map.put("lockInMonths", ext.getLockInMonths()); + map.put("interestOnPreclosure", ext.getInterestOnPreclosure()); + map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths()); + map.put("insuranceRequired", ext.getInsuranceRequired()); + map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths()); + map.put("chequeBookEnabled", ext.getChequeBookEnabled()); + map.put("chequeType", ext.getChequeType()); + map.put("defaultChequeBookSize", ext.getDefaultChequeBookSize()); + map.put("ppsThreshold", ext.getPpsThreshold()); + map.put("ppsMandatoryAbove", ext.getPpsMandatoryAbove()); + map.put("shareCapitalGl", ext.getShareCapitalGlId()); + map.put("eligibleDepositProducts", ext.getEligibleDepositProducts()); + map.put("lienTenureNotExceedMaturity", ext.getLienTenureNotExceedMaturity()); + map.put("lienAutoReleaseOnClosure", ext.getLienAutoReleaseOnClosure()); + map.put("lienAllowMultipleDeposits", ext.getLienAllowMultipleDeposits()); + map.put("lienAdjustFromMaturity", ext.getLienAdjustFromMaturity()); + map.put("lienAllowThirdParty", ext.getLienAllowThirdParty()); + map.put("lienAllowRenewalUnderLien", ext.getLienAllowRenewalUnderLien()); + map.put("repaymentType", ext.getRepaymentType()); + map.put("appropriationOrder", ext.getAppropriationOrder()); + map.put("debitBalanceAllow", ext.getDebitBalanceAllow()); + map.put("allowDebitAfterDueDate", ext.getAllowDebitAfterDueDate()); + map.put("branchLevelAccountType", ext.getBranchLevelAccountType()); + map.put("interestCalcMethod", ext.getInterestCalcMethod()); + map.put("interestOnCreditBalance", ext.getInterestOnCreditBalance()); + map.put("interestPostingDay", ext.getInterestPostingDay()); + map.put("npaUpgradeOnlyAllArrears", ext.getNpaUpgradeOnlyAllArrears()); + map.put("npaKeepStandardIfDepositCovers", ext.getNpaKeepStandardIfDepositCovers()); + map.put("chequeBookSizes", ext.getChequeBookSizes()); + map.put("chequeMaxBooksOutstanding", ext.getChequeMaxBooksOutstanding()); + map.put("chequeReissueWhenLeaves", ext.getChequeReissueWhenLeaves()); + map.put("chequeFirstBookFree", ext.getChequeFirstBookFree()); + map.put("chequeFreeBooksPerYear", ext.getChequeFreeBooksPerYear()); + map.put("chequeNamePrinted", ext.getChequeNamePrinted()); + map.put("chequePrintDescription", ext.getChequePrintDescription()); + map.put("chequeStopPaymentAllowed", ext.getChequeStopPaymentAllowed()); + map.put("chequeMaintainStockRegister", ext.getChequeMaintainStockRegister()); + map.put("chequeAllowWithdrawalSlip", ext.getChequeAllowWithdrawalSlip()); + map.put("chequeBlockNpa", ext.getChequeBlockNpa()); + map.put("chequeBlockLimitExpired", ext.getChequeBlockLimitExpired()); + map.put("chequeBlockOverDp", ext.getChequeBlockOverDp()); + map.put("chequeBlockFrozen", ext.getChequeBlockFrozen()); + map.put("chequeBlockLienReleased", ext.getChequeBlockLienReleased()); + return map; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductWritePlatformServiceJpaRepositoryImpl.java index 3b78f94d0..08cff1172 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductWritePlatformServiceJpaRepositoryImpl.java @@ -97,6 +97,8 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro private final CreditAllocationsJsonParser creditAllocationsJsonParser; private final LoanProductAssembler loanProductAssembler; private final LoanProductUpdateUtil loanProductUpdateUtil; + // "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book) that m_product_loan has no home for. + private final LoanProductAdvanceExtService loanProductAdvanceExtService; private final LoanProductPaymentAllocationRuleMerger loanProductPaymentAllocationRuleMerger = new LoanProductPaymentAllocationRuleMerger(); private final LoanProductCreditAllocationRuleMerger loanProductCreditAllocationRuleMerger = new LoanProductCreditAllocationRuleMerger(); @@ -141,6 +143,10 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro this.loanProductRepository.saveAndFlush(loanProduct); + // "Advances A/C" satellite fields, keyed on the product's generated id. A standard product carries + // none of these parameters and writes no row. Same transaction: a failure rolls the product back. + this.loanProductAdvanceExtService.saveOrUpdate(loanProduct, command); + // save accounting mappings this.accountMappingWritePlatformService.createLoanProductToGLAccountMapping(loanProduct.getId(), command); // check if the office specific products are enabled. If yes, then @@ -303,6 +309,11 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro this.loanProductRepository.saveAndFlush(product); } + // "Advances A/C" satellite fields. Outside the changes check above (those track only the standard + // product columns) so an edit that touched only an advance field is still saved, and its per-field + // diff is folded into the same audit changes map. + changes.putAll(this.loanProductAdvanceExtService.saveOrUpdate(product, command)); + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(loanProductId) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/starter/LoanProductConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/starter/LoanProductConfiguration.java index e5617d0c1..a791d9ac8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/starter/LoanProductConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/starter/LoanProductConfiguration.java @@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService; import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformServiceImpl; import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService; +import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService; import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformServiceJpaRepositoryImpl; import org.apache.fineract.portfolio.rate.domain.RateRepositoryWrapper; import org.apache.fineract.portfolio.rate.service.RateReadService; @@ -82,11 +83,12 @@ public class LoanProductConfiguration { DelinquencyBucketRepository delinquencyBucketRepository, LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory, AdvancedPaymentAllocationsJsonParser advancedPaymentJsonParser, CreditAllocationsJsonParser creditAllocationsJsonParser, - LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil) { + LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil, + LoanProductAdvanceExtService loanProductAdvanceExtService) { return new LoanProductWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, loanProductRepository, aprCalculator, fundRepository, chargeRepository, rateRepository, accountMappingWritePlatformService, fineractEntityAccessUtil, floatingRateRepository, loanRepositoryWrapper, businessEventNotifierService, delinquencyBucketRepository, loanRepaymentScheduleTransactionProcessorFactory, advancedPaymentJsonParser, creditAllocationsJsonParser, - loanProductAssembler, loanProductUpdateUtil); + loanProductAssembler, loanProductUpdateUtil, loanProductAdvanceExtService); } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index b77490e94..ed7e1ca26 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -615,4 +615,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_loan_product_advance_ext.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_loan_product_advance_ext.xml new file mode 100644 index 000000000..f5653ca29 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_create_loan_product_advance_ext.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_loan_product_advance_ext_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_loan_product_advance_ext_fields.xml new file mode 100644 index 000000000..9fa3f130d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_loan_product_advance_ext_fields.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From d1e94fd6a39e459c29303e57fc11473ba95575bb Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Mon, 5 Oct 2026 16:13:35 +0530 Subject: [PATCH 2/4] Add Fees-Penalty-Interest-Principal repayment transaction processor --- .../core/config/FineractProperties.java | 1 + ...RepaymentScheduleTransactionProcessor.java | 189 ++++++++++++++++++ ...ScheduleTransactionProcessorCondition.java | 30 +++ .../starter/LoanAccountAutoStarter.java | 10 + .../src/main/resources/application.properties | 1 + .../resources/application-test.properties | 1 + 6 files changed, 232 insertions(+) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.java diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java index 26100f390..aa74858ce 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java @@ -505,6 +505,7 @@ public class FineractProperties { private FineractTransactionProcessorItemProperties heavensFamily; private FineractTransactionProcessorItemProperties interestPrincipalPenaltiesFees; private FineractTransactionProcessorItemProperties principalInterestPenaltiesFees; + private FineractTransactionProcessorItemProperties feesPenaltiesInterestPrincipal; private FineractTransactionProcessorItemProperties rbiIndia; private FineractTransactionProcessorItemProperties duePenaltyFeeInterestPrincipalInAdvancePrincipalPenaltyFeeInterest; private FineractTransactionProcessorItemProperties duePenaltyInterestPrincipalFeeInAdvancePenaltyInterestPrincipalFee; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor.java new file mode 100644 index 000000000..d615d2298 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor.java @@ -0,0 +1,189 @@ +/** + * 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.loanaccount.domain.transactionprocessor.impl; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator; +import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService; + +/** + * {@link LoanRepaymentScheduleTransactionProcessor} that pays off components in order of fees, penalties, interest, + * then principal. + * + * Other formulas exist on fineract where you can choose 'Declining-Balance Interest Recalculation' which simply means, + * recalculate the interest component based on the how much principal is outstanding at a point in time; but this isnt + * trying to model that option only the basic one for now. + */ +@SuppressWarnings("unused") +public class FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor { + + public static final String STRATEGY_CODE = "fees-penalties-interest-principal-order-strategy"; + + public static final String STRATEGY_NAME = "Fees, Penalties, Interest, Principal Order"; + + public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory, + final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) { + super(externalIdFactory, loanChargeValidator, loanBalanceService); + } + + @Override + public String getCode() { + return STRATEGY_CODE; + } + + @Override + public String getName() { + return STRATEGY_NAME; + } + + /** + * For early/'in advance' repayments, pay off in the same way as on-time payments, interest first then principal. + */ + @Override + protected Money handleTransactionThatIsPaymentInAdvanceOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment, + final List installments, final LoanTransaction loanTransaction, final Money paymentInAdvance, + List transactionMappings, Set charges) { + + return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, paymentInAdvance, transactionMappings, + charges); + } + + /** + * For late repayments, pay off in the same way as on-time payments, interest first then principal. + */ + @Override + protected Money handleTransactionThatIsALateRepaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment, + final List installments, final LoanTransaction loanTransaction, + final Money transactionAmountUnprocessed, List transactionMappings, + Set charges) { + + return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, transactionAmountUnprocessed, + transactionMappings, charges); + } + + /** + * For normal on-time repayments, pays off interest first, then principal. + */ + @Override + protected Money handleTransactionThatIsOnTimePaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment, + final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed, + List transactionMappings, Set charges) { + + final LocalDate transactionDate = loanTransaction.getTransactionDate(); + final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency(); + Money transactionAmountRemaining = transactionAmountUnprocessed; + Money principalPortion = Money.zero(transactionAmountRemaining.getCurrency()); + Money interestPortion = Money.zero(transactionAmountRemaining.getCurrency()); + Money feeChargesPortion = Money.zero(transactionAmountRemaining.getCurrency()); + Money penaltyChargesPortion = Money.zero(transactionAmountRemaining.getCurrency()); + + if (loanTransaction.isChargesWaiver()) { + penaltyChargesPortion = currentInstallment.waivePenaltyChargesComponent(transactionDate, + loanTransaction.getPenaltyChargesPortion(currency)); + transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion); + + feeChargesPortion = currentInstallment.waiveFeeChargesComponent(transactionDate, + loanTransaction.getFeeChargesPortion(currency)); + transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion); + + } else if (loanTransaction.isInterestWaiver()) { + interestPortion = currentInstallment.waiveInterestComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion); + + loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion); + } else if (loanTransaction.isChargePayment()) { + if (loanTransaction.isPenaltyPayment()) { + penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion); + } else { + feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion); + } + loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion); + } else { + feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion); + + penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion); + + interestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion); + + principalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion); + + loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion); + } + if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) { + transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment, + principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion)); + } + return transactionAmountRemaining; + } + + @Override + protected Money handleRefundTransactionPaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment, + final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed, + List transactionMappings) { + + final LocalDate transactionDate = loanTransaction.getTransactionDate(); + final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency(); + Money transactionAmountRemaining = transactionAmountUnprocessed; + Money principalPortion = Money.zero(currency); + Money interestPortion = Money.zero(currency); + Money feeChargesPortion = Money.zero(currency); + Money penaltyChargesPortion = Money.zero(currency); + + principalPortion = currentInstallment.unpayPrincipalComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion); + + if (transactionAmountRemaining.isGreaterThanZero()) { + interestPortion = currentInstallment.unpayInterestComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion); + } + + if (transactionAmountRemaining.isGreaterThanZero()) { + penaltyChargesPortion = currentInstallment.unpayPenaltyChargesComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion); + } + + if (transactionAmountRemaining.isGreaterThanZero()) { + feeChargesPortion = currentInstallment.unpayFeeChargesComponent(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion); + } + loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion); + if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) { + transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment, + principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion)); + } + return transactionAmountRemaining; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.java new file mode 100644 index 000000000..e7f031cd0 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.java @@ -0,0 +1,30 @@ +/** + * 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.loanaccount.starter; + +import org.apache.fineract.infrastructure.core.condition.PropertiesCondition; +import org.apache.fineract.infrastructure.core.config.FineractProperties; + +public class FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition extends PropertiesCondition { + + @Override + protected boolean matches(FineractProperties properties) { + return properties.getLoan().getTransactionProcessor().getFeesPenaltiesInterestPrincipal().isEnabled(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java index be1fadf72..ed20155c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java @@ -27,6 +27,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.EarlyPaymentLoanRepaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor; @@ -98,6 +99,15 @@ public class LoanAccountAutoStarter { loanBalanceService); } + @Bean + @Conditional(FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.class) + public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor feesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor( + final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator, + final LoanBalanceService loanBalanceService) { + return new FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator, + loanBalanceService); + } + @Bean @Conditional(RBIIndiaLoanRepaymentScheduleTransactionProcessorCondition.class) public RBILoanRepaymentScheduleTransactionProcessor rbiLoanRepaymentScheduleTransactionProcessor( diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 020cdbd2f..833c2a0f5 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -170,6 +170,7 @@ fineract.loan.transactionprocessor.mifos-standard.enabled=${FINERACT_LOAN_TRANSA fineract.loan.transactionprocessor.heavensfamily.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_HEAVENSFAMILY_ENABLED:true} fineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_INTEREST_PRINCIPAL_PENALTIES_FEES_ENABLED:true} fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_PRINCIPAL_INTEREST_PENALTIES_FEES_ENABLED:true} +fineract.loan.transactionprocessor.fees-penalties-interest-principal.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_FEES_PENALTIES_INTEREST_PRINCIPAL_ENABLED:true} fineract.loan.transactionprocessor.rbi-india.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_RBI_INDIA_ENABLED:true} fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_ENABLED:true} fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_ENABLED:true} diff --git a/fineract-provider/src/test/resources/application-test.properties b/fineract-provider/src/test/resources/application-test.properties index 541be1632..f78e5a82e 100644 --- a/fineract-provider/src/test/resources/application-test.properties +++ b/fineract-provider/src/test/resources/application-test.properties @@ -87,6 +87,7 @@ fineract.loan.transactionprocessor.mifos-standard.enabled=true fineract.loan.transactionprocessor.heavensfamily.enabled=true fineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=true fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=true +fineract.loan.transactionprocessor.fees-penalties-interest-principal.enabled=true fineract.loan.transactionprocessor.rbi-india.enabled=true fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=true fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=true From b7265770315e541ad7caa86612912b8a5bd4c50f Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Mon, 5 Oct 2026 16:14:54 +0530 Subject: [PATCH 3/4] Update default DB password in application.properties Co-Authored-By: Claude Sonnet 5.5 --- fineract-provider/src/main/resources/application.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 833c2a0f5..041bef5b2 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:UAT@DB_2025} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -416,7 +416,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:UAT@DB_2025} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} From 39e3fcec59d69a1f5ab84602d42bd6e3ff906e23 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Wed, 7 Oct 2026 15:29:18 +0530 Subject: [PATCH 4/4] feat: add loan interest backend functionality --- .../service/CommandWrapperBuilder.java | 8 + .../domain/LoanRateRegisterCibilSlab.java | 76 +++++++ .../LoanRateRegisterCibilSlabRepository.java | 17 ++ .../domain/LoanRateRegisterEffective.java | 94 +++++++++ .../LoanRateRegisterEffectiveRepository.java | 24 +++ .../domain/LoanRateRegisterSlabHistory.java | 93 +++++++++ ...LoanRateRegisterSlabHistoryRepository.java | 17 ++ ...nProductsBaseInterestRatesApiResource.java | 122 +++++++++++ .../api/LoanRateRegisterSaveRequest.java | 62 ++++++ .../data/LoanRateRegisterCibilSlabData.java | 28 +++ .../data/LoanRateRegisterData.java | 38 ++++ .../data/LoanRateRegisterSlabData.java | 32 +++ ...CbsLoanBaseInterestRateCommandHandler.java | 37 ++++ .../LoanRateRegisterDataValidator.java | 164 +++++++++++++++ .../LoanRateRegisterReadPlatformService.java | 24 +++ ...anRateRegisterReadPlatformServiceImpl.java | 109 ++++++++++ .../LoanRateRegisterWritePlatformService.java | 17 ++ ...nRateRegisterWritePlatformServiceImpl.java | 103 ++++++++++ .../db/changelog/tenant/changelog-tenant.xml | 2 + .../0580_create_loan_rate_register_tables.xml | 193 ++++++++++++++++++ ...nable_loan_rate_register_maker_checker.xml | 99 +++++++++ 21 files changed, 1359 insertions(+) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlab.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlabRepository.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffective.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffectiveRepository.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistory.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistoryRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsBaseInterestRatesApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanRateRegisterSaveRequest.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterCibilSlabData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterSlabData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateCbsLoanBaseInterestRateCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanRateRegisterDataValidator.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_create_loan_rate_register_tables.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_enable_loan_rate_register_maker_checker.xml 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 af0fb4ddd..2094dda38 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 @@ -2227,6 +2227,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder createCbsLoanBaseInterestRate() { + this.actionName = "CREATE"; + this.entityName = "CBSLOANBASEINTERESTRATE"; + this.entityId = null; + this.href = "/loanproducts/baseinterestrates"; + return this; + } + public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) { this.actionName = "UPDATE"; this.entityName = "FIXEDDEPOSITPRODUCT"; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlab.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlab.java new file mode 100644 index 000000000..ea16dea10 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlab.java @@ -0,0 +1,76 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +/** + * One CIBIL/credit-score band of a rate register version — e.g. {601..650} tier "Fair" adds +1.00%. + * Applied on top of the chart cell at loan sanction. Typically 5 rows per version; ordered on reads + * by {@code displayOrder}. + */ +@Entity +@Table(name = "m_loan_rate_register_cibil_slab") +@Getter +@Setter +@NoArgsConstructor +public class LoanRateRegisterCibilSlab { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "effective_id", nullable = false) + private Long effectiveId; + + @Column(name = "score_from", nullable = false) + private Integer scoreFrom; + + @Column(name = "score_to", nullable = false) + private Integer scoreTo; + + @Column(name = "tier", length = 30, nullable = false) + private String tier; + + @Column(name = "slab_type", length = 20, nullable = false) + private String slabType; + + @Column(name = "adjustment_pct", nullable = false) + private BigDecimal adjustmentPct; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Column(name = "created_date") + private LocalDateTime createdDate; + + public static LoanRateRegisterCibilSlab create(final Long effectiveId, final Integer scoreFrom, final Integer scoreTo, final String tier, + final String slabType, final BigDecimal adjustmentPct, final Integer displayOrder) { + final LoanRateRegisterCibilSlab s = new LoanRateRegisterCibilSlab(); + s.effectiveId = effectiveId; + s.scoreFrom = scoreFrom; + s.scoreTo = scoreTo; + s.tier = tier; + s.slabType = slabType != null ? slabType : "Flat"; + s.adjustmentPct = adjustmentPct; + s.displayOrder = displayOrder; + s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + return s; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlabRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlabRepository.java new file mode 100644 index 000000000..1a0247d1f --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlabRepository.java @@ -0,0 +1,17 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LoanRateRegisterCibilSlabRepository extends JpaRepository { + + /** CIBIL bands for a version, ordered as the UI shows them (ascending score bands). */ + List findByEffectiveIdOrderByDisplayOrderAsc(Long effectiveId); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffective.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffective.java new file mode 100644 index 000000000..a07160535 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffective.java @@ -0,0 +1,94 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +/** + * One effective (versioned) copy of the central Loan Interest Rate Register. Mirrors the + * {@code FdBaseInterestRateEffective} versioning model: each approved save creates a new row with + * {@code isActive=true} and deactivates the previous active row ({@code isActive=false, + * effectiveToDate=today}). Reads for "current active" filter on {@code isActive=true}. + * + * The 34-product × 10-slab rate matrix for this version lives in {@link LoanRateRegisterSlabHistory}; + * the CIBIL/credit-score bands live in {@link LoanRateRegisterCibilSlab}, both keyed by this row's + * id and cascade-deleted with it. + */ +@Entity +@Table(name = "m_loan_rate_register_effective") +@Getter +@Setter +@NoArgsConstructor +public class LoanRateRegisterEffective { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", length = 200, nullable = false) + private String name; + + @Column(name = "effective_from_date", nullable = false) + private LocalDate effectiveFromDate; + + @Column(name = "effective_to_date") + private LocalDate effectiveToDate; + + @Column(name = "currency_code", length = 3, nullable = false) + private String currencyCode; + + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @Column(name = "fallback_no_cibil_slab", nullable = false) + private Integer fallbackNoCibilSlab; + + @Column(name = "fallback_below_min", length = 20, nullable = false) + private String fallbackBelowMin; + + @Column(name = "note", length = 500) + private String note; + + @Column(name = "created_by_id") + private Long createdById; + + @Column(name = "created_date") + private LocalDateTime createdDate; + + public static LoanRateRegisterEffective create(final String name, final LocalDate effectiveFromDate, final String currencyCode, + final Integer fallbackNoCibilSlab, final String fallbackBelowMin, final String note, final Long createdById) { + final LoanRateRegisterEffective e = new LoanRateRegisterEffective(); + e.name = name; + e.effectiveFromDate = effectiveFromDate; + e.currencyCode = currencyCode != null ? currencyCode : "INR"; + e.isActive = true; + e.fallbackNoCibilSlab = fallbackNoCibilSlab != null ? fallbackNoCibilSlab : 2; + e.fallbackBelowMin = fallbackBelowMin != null ? fallbackBelowMin : "block"; + e.note = note; + e.createdById = createdById; + e.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + return e; + } + + /** Flip this version to inactive and close its effective window. Called when a newer version is approved. */ + public void deactivate(final LocalDate toDate) { + this.isActive = false; + this.effectiveToDate = toDate; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffectiveRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffectiveRepository.java new file mode 100644 index 000000000..7e1dd1608 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffectiveRepository.java @@ -0,0 +1,24 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LoanRateRegisterEffectiveRepository extends JpaRepository { + + /** All active versions, newest-effective first. At most one row in a healthy system. */ + List findByIsActiveTrueOrderByEffectiveFromDateDesc(); + + /** The current active version, if any. */ + Optional findFirstByIsActiveTrueOrderByEffectiveFromDateDesc(); + + /** Version history list — all versions, newest first. */ + List findAllByOrderByEffectiveFromDateDesc(); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistory.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistory.java new file mode 100644 index 000000000..95d40e024 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistory.java @@ -0,0 +1,93 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.service.DateUtils; + +/** + * A single cell of a rate register version — one row per {product × amount-slab}. Product and slab + * metadata are denormalised so one query can rebuild the full matrix without a join. The cell holds + * either a plain numeric rate, a formula string ({@code "1% + FD"}) resolved at sanction, or + * N.A. (both value and formula null, {@code isNa=true}) to mean "slab not offered". + */ +@Entity +@Table(name = "m_loan_rate_register_slab_history") +@Getter +@Setter +@NoArgsConstructor +public class LoanRateRegisterSlabHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "effective_id", nullable = false) + private Long effectiveId; + + // ---- product dimension ---------------------------------------------- + @Column(name = "product_sr_no", nullable = false) + private Integer productSrNo; + + @Column(name = "product_name", length = 200, nullable = false) + private String productName; + + // ---- amount-slab dimension ------------------------------------------ + @Column(name = "slab_order", nullable = false) + private Integer slabOrder; + + @Column(name = "slab_label", length = 60, nullable = false) + private String slabLabel; + + @Column(name = "amount_upto") + private BigDecimal amountUpto; + + // ---- cell value (exactly one of value / formula is populated; both null + isNa=true means N.A.) ---- + @Column(name = "rate_value") + private BigDecimal rateValue; + + @Column(name = "rate_formula", length = 50) + private String rateFormula; + + @Column(name = "is_formula", nullable = false) + private Boolean isFormula; + + @Column(name = "is_na", nullable = false) + private Boolean isNa; + + @Column(name = "created_date") + private LocalDateTime createdDate; + + public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Integer productSrNo, final String productName, + final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto, final BigDecimal rateValue, + final String rateFormula, final boolean isFormula, final boolean isNa) { + final LoanRateRegisterSlabHistory s = new LoanRateRegisterSlabHistory(); + s.effectiveId = effectiveId; + s.productSrNo = productSrNo; + s.productName = productName; + s.slabOrder = slabOrder; + s.slabLabel = slabLabel; + s.amountUpto = amountUpto; + s.rateValue = rateValue; + s.rateFormula = rateFormula; + s.isFormula = isFormula; + s.isNa = isNa; + s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); + return s; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistoryRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistoryRepository.java new file mode 100644 index 000000000..82e18aaab --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistoryRepository.java @@ -0,0 +1,17 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LoanRateRegisterSlabHistoryRepository extends JpaRepository { + + /** All cells of a version, ordered by product then by amount-slab. Rebuilds the full matrix in order. */ + List findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(Long effectiveId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsBaseInterestRatesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsBaseInterestRatesApiResource.java new file mode 100644 index 000000000..0d47cb3e7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsBaseInterestRatesApiResource.java @@ -0,0 +1,122 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializer; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData; +import org.apache.fineract.portfolio.loanproduct.service.LoanRateRegisterReadPlatformService; +import org.springframework.stereotype.Component; + +/** + * REST surface for the Loan Interest Rate Register. Mirrors + * {@code FixedDepositProductsBaseInterestRatesApiResource}: a GET list for the Version History + * accordion, a GET /active for the current rate chart, a GET /{id} for one specific version, and + * a POST that goes through Fineract's maker-checker pipeline. + * + * Reads are gated by the pre-existing {@code LOANPRODUCT} resource; writes by the two seeded + * permissions {@code CREATE_CBSLOANBASEINTERESTRATE} (maker) and {@code CREATE_CBSLOANBASEINTERESTRATE_CHECKER}. + */ +@Path("/v1/loanproducts/baseinterestrates") +@Component +@RequiredArgsConstructor +public class LoanProductsBaseInterestRatesApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "LOANPRODUCT"; + + private final PlatformSecurityContext context; + private final LoanRateRegisterReadPlatformService readService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + private final Gson gson = new GsonBuilder() + .registerTypeAdapter(LocalDate.class, + (JsonSerializer) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString())) + .registerTypeAdapter(LocalDateTime.class, + (JsonSerializer) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))) + .create(); + + /** Full Version History — all effective versions, newest first. */ + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Get all Loan Interest Rate Register versions", description = "Returns the full history of rate register versions, newest first.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public String retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final List data = this.readService.retrieveAll(); + return this.gson.toJson(data); + } + + /** The currently in-force version, with its full chart + CIBIL. */ + @GET + @Path("active") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Get the active Loan Interest Rate Register", description = "Returns the version flagged isActive=true, or null when none has been approved yet.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public String retrieveActive() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final Optional data = this.readService.retrieveActive(); + return this.gson.toJson(data.orElse(null)); + } + + /** One specific version by id — used by the Compare screen for the "will be deactivated" baseline. */ + @GET + @Path("{id}") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Get a specific rate register version", description = "Returns the full chart + CIBIL bands for the given version id.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK"), @ApiResponse(responseCode = "404", description = "Not found") }) + public String retrieveById(@PathParam("id") final Long id) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final Optional data = this.readService.retrieveById(id); + return this.gson.toJson(data.orElse(null)); + } + + /** + * Submit a new version. If {@code CREATE_CBSLOANBASEINTERESTRATE.can_maker_checker=true}, this + * returns {@code rollbackTransaction=true} and the row sits in {@code m_portfolio_command_source} + * until a checker approves it from the Approvals tab. Otherwise it commits immediately. + */ + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Submit a new Loan Interest Rate Register version", description = "Creates a new version of the loan interest rate register. Deactivates the current active version on approval.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public CommandProcessingResult submitRateRegister(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .createCbsLoanBaseInterestRate() + .withJson(apiRequestBodyAsJson) + .build(); + return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanRateRegisterSaveRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanRateRegisterSaveRequest.java new file mode 100644 index 000000000..175f68476 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanRateRegisterSaveRequest.java @@ -0,0 +1,62 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.api; + +import java.math.BigDecimal; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * JSON shape expected by {@code POST /loanproducts/baseinterestrates}. Deserialised by Gson in the + * write service. One request = one proposed new version of the rate register. + */ +@Getter +@Setter +@NoArgsConstructor +public class LoanRateRegisterSaveRequest { + + private String name; + /** ISO date "YYYY-MM-DD". Defaults to today when omitted. */ + private String effectiveFromDate; + private String currencyCode; + private Integer fallbackNoCibilSlab; + private String fallbackBelowMin; + private String note; + + private List slabs; + private List cibilSlabs; + + @Getter + @Setter + @NoArgsConstructor + public static class SlabRequest { + private Integer productSrNo; + private String productName; + private Integer slabOrder; + private String slabLabel; + private BigDecimal amountUpto; + private BigDecimal rateValue; + private String rateFormula; + private Boolean isFormula; + private Boolean isNa; + } + + @Getter + @Setter + @NoArgsConstructor + public static class CibilSlabRequest { + private Integer scoreFrom; + private Integer scoreTo; + private String tier; + private String slabType; + private BigDecimal adjustmentPct; + private Integer displayOrder; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterCibilSlabData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterCibilSlabData.java new file mode 100644 index 000000000..be88dd50d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterCibilSlabData.java @@ -0,0 +1,28 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.data; + +import java.math.BigDecimal; +import lombok.Builder; +import lombok.Getter; + +/** + * One CIBIL/credit-score band of a rate register version, used in the response payload. + */ +@Getter +@Builder +public class LoanRateRegisterCibilSlabData { + + private Long id; + private Integer scoreFrom; + private Integer scoreTo; + private String tier; + private String slabType; + private BigDecimal adjustmentPct; + private Integer displayOrder; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterData.java new file mode 100644 index 000000000..4aec4c173 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterData.java @@ -0,0 +1,38 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.data; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +/** + * Response DTO for one effective version of the Loan Interest Rate Register. Header fields + + * nested chart cells + CIBIL bands. + */ +@Getter +@Builder +public class LoanRateRegisterData { + + private Long id; + private String name; + private LocalDate effectiveFromDate; + private LocalDate effectiveToDate; + private String currencyCode; + private Boolean isActive; + private Integer fallbackNoCibilSlab; + private String fallbackBelowMin; + private String note; + private Long createdById; + private LocalDateTime createdDate; + + private List slabs; + private List cibilSlabs; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterSlabData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterSlabData.java new file mode 100644 index 000000000..016f98b16 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterSlabData.java @@ -0,0 +1,32 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.data; + +import java.math.BigDecimal; +import lombok.Builder; +import lombok.Getter; + +/** + * One cell of a rate register version — same shape as the entity but a POJO for the response. + * Serialised through Gson in the REST resource. + */ +@Getter +@Builder +public class LoanRateRegisterSlabData { + + private Long id; + private Integer productSrNo; + private String productName; + private Integer slabOrder; + private String slabLabel; + private BigDecimal amountUpto; + private BigDecimal rateValue; + private String rateFormula; + private Boolean isFormula; + private Boolean isNa; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateCbsLoanBaseInterestRateCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateCbsLoanBaseInterestRateCommandHandler.java new file mode 100644 index 000000000..0e6f761b6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateCbsLoanBaseInterestRateCommandHandler.java @@ -0,0 +1,37 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.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.loanproduct.service.LoanRateRegisterWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Dispatched by Fineract when a {@code CBSLOANBASEINTERESTRATE} / {@code CREATE} command is executed — + * on first submit (maker-checker off) or on checker approval (maker-checker on). The {@code @CommandType} + * entity string here MUST match the one registered by {@code CommandWrapperBuilder.createCbsLoanBaseInterestRate()} + * and the permission codes seeded by {@code 0581_enable_loan_rate_register_maker_checker.xml}. + */ +@Service +@CommandType(entity = "CBSLOANBASEINTERESTRATE", action = "CREATE") +@RequiredArgsConstructor +public class CreateCbsLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler { + + private final LoanRateRegisterWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.saveRateRegister(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanRateRegisterDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanRateRegisterDataValidator.java new file mode 100644 index 000000000..aaa855c05 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanRateRegisterDataValidator.java @@ -0,0 +1,164 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.serialization; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest; +import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest; +import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest; +import org.springframework.stereotype.Component; + +/** + * Validates a {@link LoanRateRegisterSaveRequest} before the write service touches the DB. Keeps + * the write service free of guard clauses and surfaces every error up front rather than failing on + * the first one — Fineract convention. + */ +@Component +public class LoanRateRegisterDataValidator { + + private static final String RESOURCE_NAME = "loanRateRegister"; + + public void validate(final LoanRateRegisterSaveRequest req) { + final List errors = new ArrayList<>(); + + if (req == null) { + errors.add(error("payload", "payload.required", "Request body is required")); + raise(errors); + } + + // Header fields + if (isBlank(req.getName())) { + errors.add(error("name", "name.required", "Register name is required")); + } else if (req.getName().length() > 200) { + errors.add(error("name", "name.exceeds.max.length", "Register name must be at most 200 characters")); + } + if (req.getFallbackNoCibilSlab() != null && req.getFallbackNoCibilSlab() < 1) { + errors.add(error("fallbackNoCibilSlab", "fallback.no.cibil.slab.invalid", "Fallback slab pointer must be >= 1")); + } + if (req.getFallbackBelowMin() != null && !req.getFallbackBelowMin().equals("use-min-slab") + && !req.getFallbackBelowMin().equals("block")) { + errors.add(error("fallbackBelowMin", "fallback.below.min.invalid", + "fallbackBelowMin must be 'use-min-slab' or 'block'")); + } + + // Chart cells + final List slabs = req.getSlabs(); + if (slabs == null || slabs.isEmpty()) { + errors.add(error("slabs", "slabs.required", "At least one rate chart cell is required")); + } else { + for (int i = 0; i < slabs.size(); i++) { + validateSlab(slabs.get(i), i, errors); + } + } + + // CIBIL bands + final List cibil = req.getCibilSlabs(); + if (cibil == null || cibil.isEmpty()) { + errors.add(error("cibilSlabs", "cibilSlabs.required", "At least one CIBIL slab is required")); + } else { + validateCibilCoverage(cibil, errors); + } + + raise(errors); + } + + private void validateSlab(final SlabRequest s, final int idx, final List errors) { + final String prefix = "slabs[" + idx + "]"; + if (s.getProductSrNo() == null) { + errors.add(error(prefix + ".productSrNo", "productSrNo.required", "productSrNo is required")); + } + if (isBlank(s.getProductName())) { + errors.add(error(prefix + ".productName", "productName.required", "productName is required")); + } + if (s.getSlabOrder() == null) { + errors.add(error(prefix + ".slabOrder", "slabOrder.required", "slabOrder is required")); + } + if (isBlank(s.getSlabLabel())) { + errors.add(error(prefix + ".slabLabel", "slabLabel.required", "slabLabel is required")); + } + // Exactly one of: rateValue populated, rateFormula populated, isNa=true. Both-null-and-not-NA is invalid. + final boolean hasValue = s.getRateValue() != null; + final boolean hasFormula = s.getRateFormula() != null && !s.getRateFormula().isBlank(); + final boolean na = Boolean.TRUE.equals(s.getIsNa()); + final int populated = (hasValue ? 1 : 0) + (hasFormula ? 1 : 0) + (na ? 1 : 0); + if (populated == 0) { + errors.add(error(prefix, "cell.empty", + "Cell must have a rateValue, a rateFormula, or isNa=true")); + } else if (populated > 1) { + errors.add(error(prefix, "cell.ambiguous", + "Cell must have exactly one of rateValue, rateFormula, or isNa")); + } + if (hasFormula && !Boolean.TRUE.equals(s.getIsFormula())) { + errors.add(error(prefix + ".isFormula", "isFormula.mismatch", + "isFormula must be true when rateFormula is set")); + } + } + + /** CIBIL bands must cover 0..900 contiguously and must not overlap. */ + private void validateCibilCoverage(final List cibil, final List errors) { + final List sorted = new ArrayList<>(cibil); + sorted.sort((a, b) -> Integer.compare(nz(a.getScoreFrom()), nz(b.getScoreFrom()))); + final Set seenOrders = new TreeSet<>(); + int expectedNextFrom = 0; + for (int i = 0; i < sorted.size(); i++) { + final CibilSlabRequest c = sorted.get(i); + final String prefix = "cibilSlabs[" + i + "]"; + if (c.getScoreFrom() == null || c.getScoreTo() == null) { + errors.add(error(prefix, "cibil.range.required", "scoreFrom and scoreTo are required")); + continue; + } + if (c.getScoreFrom() > c.getScoreTo()) { + errors.add(error(prefix, "cibil.range.inverted", "scoreFrom must be <= scoreTo")); + } + if (c.getScoreFrom() != expectedNextFrom) { + errors.add(error(prefix, "cibil.range.gap_or_overlap", + "CIBIL bands must be contiguous starting from 0 (expected " + expectedNextFrom + + ", got " + c.getScoreFrom() + ")")); + } + if (isBlank(c.getTier())) { + errors.add(error(prefix + ".tier", "tier.required", "tier is required")); + } + if (c.getAdjustmentPct() == null) { + errors.add(error(prefix + ".adjustmentPct", "adjustmentPct.required", "adjustmentPct is required")); + } + if (c.getDisplayOrder() == null || !seenOrders.add(c.getDisplayOrder())) { + errors.add(error(prefix + ".displayOrder", "displayOrder.invalid", + "displayOrder is required and must be unique")); + } + expectedNextFrom = c.getScoreTo() + 1; + } + if (!sorted.isEmpty() && expectedNextFrom <= 900) { + errors.add(error("cibilSlabs", "cibil.coverage.incomplete", + "CIBIL bands must cover up to 900 (last to was " + (expectedNextFrom - 1) + ")")); + } + } + + private static ApiParameterError error(final String field, final String code, final String msg) { + return ApiParameterError.parameterError("validation." + RESOURCE_NAME + "." + code, msg, field); + } + + private static void raise(final List errors) { + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg." + RESOURCE_NAME + ".failed", + "Validation errors exist.", errors); + } + } + + private static boolean isBlank(final String s) { + return s == null || s.isBlank(); + } + + private static int nz(final Integer n) { + return n != null ? n : 0; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformService.java new file mode 100644 index 000000000..dba4416db --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformService.java @@ -0,0 +1,24 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.service; + +import java.util.List; +import java.util.Optional; +import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData; + +public interface LoanRateRegisterReadPlatformService { + + /** All versions, newest-effective first — the Version History accordion feeds off this. */ + List retrieveAll(); + + /** The current active version, assembled with its chart cells + CIBIL bands. */ + Optional retrieveActive(); + + /** A specific version by id. */ + Optional retrieveById(Long id); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformServiceImpl.java new file mode 100644 index 000000000..02c131798 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformServiceImpl.java @@ -0,0 +1,109 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterCibilSlabData; +import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData; +import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterSlabData; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository; +import org.springframework.stereotype.Service; + +/** + * Assembles {@link LoanRateRegisterData} responses by joining the three physical tables. Kept + * read-only — all writes go through {@link LoanRateRegisterWritePlatformService}. + */ +@Service +@RequiredArgsConstructor +public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegisterReadPlatformService { + + private final LoanRateRegisterEffectiveRepository effectiveRepository; + private final LoanRateRegisterSlabHistoryRepository slabRepository; + private final LoanRateRegisterCibilSlabRepository cibilRepository; + + @Override + public List retrieveAll() { + final List versions = this.effectiveRepository.findAllByOrderByEffectiveFromDateDesc(); + final List out = new ArrayList<>(versions.size()); + for (final LoanRateRegisterEffective v : versions) { + out.add(assemble(v)); + } + return out; + } + + @Override + public Optional retrieveActive() { + return this.effectiveRepository.findFirstByIsActiveTrueOrderByEffectiveFromDateDesc().map(this::assemble); + } + + @Override + public Optional retrieveById(final Long id) { + return this.effectiveRepository.findById(id).map(this::assemble); + } + + /** Build the full response for one effective version — header + its chart cells + its CIBIL bands. */ + private LoanRateRegisterData assemble(final LoanRateRegisterEffective v) { + final List cells = this.slabRepository + .findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(v.getId()); + final List bands = this.cibilRepository + .findByEffectiveIdOrderByDisplayOrderAsc(v.getId()); + + final List slabData = new ArrayList<>(cells.size()); + for (final LoanRateRegisterSlabHistory c : cells) { + slabData.add(LoanRateRegisterSlabData.builder() + .id(c.getId()) + .productSrNo(c.getProductSrNo()) + .productName(c.getProductName()) + .slabOrder(c.getSlabOrder()) + .slabLabel(c.getSlabLabel()) + .amountUpto(c.getAmountUpto()) + .rateValue(c.getRateValue()) + .rateFormula(c.getRateFormula()) + .isFormula(c.getIsFormula()) + .isNa(c.getIsNa()) + .build()); + } + + final List cibilData = new ArrayList<>(bands.size()); + for (final LoanRateRegisterCibilSlab b : bands) { + cibilData.add(LoanRateRegisterCibilSlabData.builder() + .id(b.getId()) + .scoreFrom(b.getScoreFrom()) + .scoreTo(b.getScoreTo()) + .tier(b.getTier()) + .slabType(b.getSlabType()) + .adjustmentPct(b.getAdjustmentPct()) + .displayOrder(b.getDisplayOrder()) + .build()); + } + + return LoanRateRegisterData.builder() + .id(v.getId()) + .name(v.getName()) + .effectiveFromDate(v.getEffectiveFromDate()) + .effectiveToDate(v.getEffectiveToDate()) + .currencyCode(v.getCurrencyCode()) + .isActive(v.getIsActive()) + .fallbackNoCibilSlab(v.getFallbackNoCibilSlab()) + .fallbackBelowMin(v.getFallbackBelowMin()) + .note(v.getNote()) + .createdById(v.getCreatedById()) + .createdDate(v.getCreatedDate()) + .slabs(slabData) + .cibilSlabs(cibilData) + .build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformService.java new file mode 100644 index 000000000..731bee525 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformService.java @@ -0,0 +1,17 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface LoanRateRegisterWritePlatformService { + + /** Save a new version of the loan interest rate register (maker-checker gated upstream). */ + CommandProcessingResult saveRateRegister(JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformServiceImpl.java new file mode 100644 index 000000000..dbf58c0e0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformServiceImpl.java @@ -0,0 +1,103 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.service; + +import com.google.gson.Gson; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest; +import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest; +import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository; +import org.apache.fineract.portfolio.loanproduct.serialization.LoanRateRegisterDataValidator; +import org.springframework.stereotype.Service; + +/** + * Persists a new effective version of the Loan Interest Rate Register. Called from the command + * handler, which Fineract routes to only AFTER a checker has approved the maker's submission. The + * whole save runs inside one transaction: previous active deactivated → new effective inserted → + * all chart cells + CIBIL bands inserted. Any failure rolls everything back. + */ +@Service +@RequiredArgsConstructor +public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegisterWritePlatformService { + + private final PlatformSecurityContext context; + private final LoanRateRegisterDataValidator validator; + private final LoanRateRegisterEffectiveRepository effectiveRepository; + private final LoanRateRegisterSlabHistoryRepository slabRepository; + private final LoanRateRegisterCibilSlabRepository cibilRepository; + private final Gson gson = new Gson(); + + @Override + public CommandProcessingResult saveRateRegister(final JsonCommand command) { + final LoanRateRegisterSaveRequest req = gson.fromJson(command.json(), LoanRateRegisterSaveRequest.class); + this.validator.validate(req); + + final Long createdById = this.context.authenticatedUser().getId(); + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate effectiveFrom = parseDateOrToday(req.getEffectiveFromDate(), today); + + // 1) Deactivate the currently-active version (if any). + final List active = this.effectiveRepository.findByIsActiveTrueOrderByEffectiveFromDateDesc(); + for (final LoanRateRegisterEffective prior : active) { + prior.deactivate(today); + } + if (!active.isEmpty()) { + this.effectiveRepository.saveAll(active); + } + + // 2) Insert the new effective row. + final LoanRateRegisterEffective fresh = LoanRateRegisterEffective.create(req.getName(), effectiveFrom, + req.getCurrencyCode(), req.getFallbackNoCibilSlab(), req.getFallbackBelowMin(), req.getNote(), createdById); + final LoanRateRegisterEffective saved = this.effectiveRepository.save(fresh); + final Long versionId = saved.getId(); + + // 3) Insert all chart cells for this version. + final List cells = new ArrayList<>(req.getSlabs().size()); + for (final SlabRequest s : req.getSlabs()) { + cells.add(LoanRateRegisterSlabHistory.create(versionId, s.getProductSrNo(), s.getProductName(), s.getSlabOrder(), + s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(), + Boolean.TRUE.equals(s.getIsFormula()), Boolean.TRUE.equals(s.getIsNa()))); + } + this.slabRepository.saveAll(cells); + + // 4) Insert all CIBIL bands for this version. + final List bands = new ArrayList<>(req.getCibilSlabs().size()); + for (final CibilSlabRequest c : req.getCibilSlabs()) { + bands.add(LoanRateRegisterCibilSlab.create(versionId, c.getScoreFrom(), c.getScoreTo(), c.getTier(), + c.getSlabType(), c.getAdjustmentPct(), c.getDisplayOrder())); + } + this.cibilRepository.saveAll(bands); + + return new CommandProcessingResultBuilder() + .withCommandId(command.commandId()) + .withEntityId(versionId) + .build(); + } + + private static LocalDate parseDateOrToday(final String iso, final LocalDate today) { + if (iso == null || iso.isBlank()) { + return today; + } + return LocalDate.parse(iso, DateTimeFormatter.ISO_LOCAL_DATE); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index ed7e1ca26..3c1bf875d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -617,4 +617,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_create_loan_rate_register_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_create_loan_rate_register_tables.xml new file mode 100644 index 000000000..85a9a1f3a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_create_loan_rate_register_tables.xml @@ -0,0 +1,193 @@ + + + + + + + + + ANY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ANY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ANY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ANY + + + + + + + + ANY + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_enable_loan_rate_register_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_enable_loan_rate_register_maker_checker.xml new file mode 100644 index 000000000..6798d45a9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_enable_loan_rate_register_maker_checker.xml @@ -0,0 +1,99 @@ + + + + + + + + ANY + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'portfolio', 'CREATE_CBSLOANBASEINTERESTRATE', 'CBSLOANBASEINTERESTRATE', 'CREATE', true + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'portfolio', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER', 'CBSLOANBASEINTERESTRATE', 'CREATE_CHECKER', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER'); + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE entity_name = 'CBSLOANBASEINTERESTRATE' + AND action_name = 'CREATE'; + + UPDATE m_permission + SET action_name = 'CREATE_CHECKER' + WHERE entity_name = 'CBSLOANBASEINTERESTRATE' + AND code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER'; + + + + + ANY + + -- Super user: both maker and checker + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE r.name = 'Super user' + AND p.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + -- bankadmin user's role(s): both maker and checker + INSERT INTO m_role_permission (role_id, permission_id) + SELECT ur.role_id, p.id + FROM m_appuser u + JOIN m_appuser_role ur ON ur.appuser_id = u.id + CROSS JOIN m_permission p + WHERE u.username = 'bankadmin' + AND p.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER') + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id + ); + + -- Clerk: maker only (separation of duties — clerks submit, higher authority approves) + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r + CROSS JOIN m_permission p + WHERE LOWER(r.name) = 'clerk' + AND p.code = 'CREATE_CBSLOANBASEINTERESTRATE' + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + + +