Aman-Mittal commented on code in PR #29: URL: https://github.com/apache/fineract-loan-origination/pull/29#discussion_r3476022461
########## src/main/java/org/apache/fineract/los/scoring/factors/DebtBurdenFactor.java: ########## @@ -0,0 +1,129 @@ +/* + * 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.los.scoring.factors; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.los.scoring.ScoringFactor; +import org.apache.fineract.los.scoring.ScoringWeightsProperties; +import org.apache.fineract.los.scoring.model.ApplicantScoringProfile; +import org.apache.fineract.los.scoring.model.FactorScore; +import org.springframework.stereotype.Component; + +/** + * Evaluates the applicant's existing debt burden relative to monthly income. + * + * <p>Computes the Debt-to-Income (DTI) ratio: {@code existingLoanObligations / monthlyIncome}. + * Lower DTI means less of the applicant's income is already committed to debt, leaving more + * capacity to service a new loan. + * + * <p>Scoring bands (DTI = existingLoanObligations / monthlyIncome): + * + * <ul> + * <li>≤ 0.20 (up to 20% of income committed) → full points + * <li>≤ 0.35 → 75% of max points + * <li>≤ 0.50 → 50% of max points + * <li>≤ 0.65 → 25% of max points + * <li>> 0.65 → 0 points + * </ul> + * + * <p>If {@code monthlyIncome} is null or zero, scores 0 — cannot compute DTI without income. + * If {@code existingLoanObligations} is null or zero, the applicant has no existing debt and + * receives full points. + */ +@Component +@RequiredArgsConstructor +public class DebtBurdenFactor implements ScoringFactor { + + private static final String FACTOR_NAME = "debt-burden"; + + private final ScoringWeightsProperties weights; + + @Override + public FactorScore score(ApplicantScoringProfile profile) { + final int max = maxPoints(); + + BigDecimal income = profile.getMonthlyIncome(); + BigDecimal obligations = profile.getExistingLoanObligations(); + + if (income == null || income.compareTo(BigDecimal.ZERO) <= 0) { + return FactorScore.builder() + .points(0) + .maxPoints(max) + .explanation("Cannot evaluate debt burden without monthly income data.") + .build(); + } + + // No existing obligations — clean slate, full points + if (obligations == null || obligations.compareTo(BigDecimal.ZERO) <= 0) { + return FactorScore.builder() + .points(max) + .maxPoints(max) + .explanation("No existing loan obligations — full debt capacity available.") + .build(); + } + + // DTI = existingObligations / monthlyIncome + BigDecimal dti = obligations.divide(income, 4, RoundingMode.HALF_UP); Review Comment: The Issue: While there is a null/zero check for income, BigDecimal.compareTo(BigDecimal.ZERO) <= 0 only flags zero or negative values. If income is a very small non-zero fraction (e.g., 0.00001), this scale allocation works, but if there's any scenario where the input validation bypasses a fraction close to zero, it's risky. More importantly, in IncomeLoanRatioFactor, if requested is zero, a standard division will throw an ArithmeticException. The Fix: Ensure your explicit guards securely capture edge-case small fractions, or rely entirely on robust validation at the ApplicantScoringProfile entry point. ########## src/main/java/org/apache/fineract/los/scoring/factors/RepaymentHistoryFactor.java: ########## @@ -0,0 +1,130 @@ +/* + * 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.los.scoring.factors; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.los.scoring.ScoringFactor; +import org.apache.fineract.los.scoring.ScoringWeightsProperties; +import org.apache.fineract.los.scoring.model.ApplicantScoringProfile; +import org.apache.fineract.los.scoring.model.FactorScore; +import org.springframework.stereotype.Component; + +/** + * Evaluates the applicant's prior loan repayment history as a credit scoring factor. + * + * <p>Uses repayment track record within Fineract to assess reliability. First-time borrowers with + * no history receive a neutral score rather than a penalty — consistent with CGAP guidelines for + * inclusive microfinance lending. + * + * <p>Scoring logic: + * + * <ol> + * <li>If no prior loans exist (both counts null or zero) → neutral score (50% of max). + * First-time borrowers are not penalised. + * <li>If prior loans exist, compute success rate: {@code successRate = successful / (successful + + * missed)} + * <ul> + * <li>≥ 0.95 (near-perfect) → full points + * <li>≥ 0.80 → 75% of max points + * <li>≥ 0.60 → 50% of max points + * <li>≥ 0.40 → 25% of max points + * <li>< 0.40 → 0 points (poor repayment history) + * </ul> + * </ol> + */ +@Component +@RequiredArgsConstructor +public class RepaymentHistoryFactor implements ScoringFactor { + + private static final String FACTOR_NAME = "repayment-history"; + + private final ScoringWeightsProperties weights; + + @Override + public FactorScore score(ApplicantScoringProfile profile) { + final int max = maxPoints(); + + int successful = + profile.getSuccessfulRepaymentsCount() != null ? profile.getSuccessfulRepaymentsCount() : 0; + int missed = + profile.getMissedRepaymentsCount() != null ? profile.getMissedRepaymentsCount() : 0; + + int total = successful + missed; + + // First-time borrower — no history, neutral score + if (total == 0) { + int neutralPoints = (int) Math.round(max * 0.50); + return FactorScore.builder() + .points(neutralPoints) + .maxPoints(max) + .explanation("No prior loan history — neutral score applied for first-time borrower.") + .build(); + } + + double successRate = (double) successful / total; + + int points; + String explanation; + + if (successRate >= 0.95) { + points = max; + explanation = + String.format( + "Excellent repayment history: %d of %d loans repaid successfully (%.0f%%).", Review Comment: I See lot of string duplication in this ########## src/main/java/org/apache/fineract/los/scoring/factors/DebtBurdenFactor.java: ########## @@ -0,0 +1,129 @@ +/* + * 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.los.scoring.factors; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.los.scoring.ScoringFactor; +import org.apache.fineract.los.scoring.ScoringWeightsProperties; +import org.apache.fineract.los.scoring.model.ApplicantScoringProfile; +import org.apache.fineract.los.scoring.model.FactorScore; +import org.springframework.stereotype.Component; + +/** + * Evaluates the applicant's existing debt burden relative to monthly income. + * + * <p>Computes the Debt-to-Income (DTI) ratio: {@code existingLoanObligations / monthlyIncome}. + * Lower DTI means less of the applicant's income is already committed to debt, leaving more + * capacity to service a new loan. + * + * <p>Scoring bands (DTI = existingLoanObligations / monthlyIncome): + * + * <ul> + * <li>≤ 0.20 (up to 20% of income committed) → full points + * <li>≤ 0.35 → 75% of max points + * <li>≤ 0.50 → 50% of max points + * <li>≤ 0.65 → 25% of max points + * <li>> 0.65 → 0 points + * </ul> + * + * <p>If {@code monthlyIncome} is null or zero, scores 0 — cannot compute DTI without income. + * If {@code existingLoanObligations} is null or zero, the applicant has no existing debt and + * receives full points. + */ +@Component +@RequiredArgsConstructor +public class DebtBurdenFactor implements ScoringFactor { + + private static final String FACTOR_NAME = "debt-burden"; + + private final ScoringWeightsProperties weights; + + @Override + public FactorScore score(ApplicantScoringProfile profile) { + final int max = maxPoints(); + + BigDecimal income = profile.getMonthlyIncome(); + BigDecimal obligations = profile.getExistingLoanObligations(); + + if (income == null || income.compareTo(BigDecimal.ZERO) <= 0) { + return FactorScore.builder() + .points(0) + .maxPoints(max) + .explanation("Cannot evaluate debt burden without monthly income data.") + .build(); + } + + // No existing obligations — clean slate, full points + if (obligations == null || obligations.compareTo(BigDecimal.ZERO) <= 0) { + return FactorScore.builder() + .points(max) + .maxPoints(max) + .explanation("No existing loan obligations — full debt capacity available.") + .build(); + } + + // DTI = existingObligations / monthlyIncome + BigDecimal dti = obligations.divide(income, 4, RoundingMode.HALF_UP); + double d = dti.doubleValue(); + + int points; + String explanation; + + if (d <= 0.20) { + points = max; + explanation = String.format( + "Debt-to-income ratio of %.0f%% is low — minimal existing debt burden.", d * 100); + } else if (d <= 0.35) { + points = (int) Math.round(max * 0.75); Review Comment: Mixing BigDecimal for financial values with double and Math.round/Math.floor for points introduces subtle rounding bugs depending on what maxPoints is set to in ScoringWeightsProperties. If max is small (e.g., 5 points), Math.round(5 * 0.75) = 4. If it's a large scale, precision drift can occur. ########## src/main/java/org/apache/fineract/los/scoring/factors/IncomeLoanRatioFactor.java: ########## @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fineract.los.scoring.factors; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.los.scoring.ScoringFactor; +import org.apache.fineract.los.scoring.ScoringWeightsProperties; +import org.apache.fineract.los.scoring.model.ApplicantScoringProfile; +import org.apache.fineract.los.scoring.model.FactorScore; +import org.springframework.stereotype.Component; + +/** + * Evaluates the income-to-loan ratio as a credit scoring factor. + * + * <p>Measures whether the applicant's monthly income is sufficient relative to the requested loan + * amount. A higher ratio indicates stronger repayment capacity. + * + * <p>Scoring bands (ratio = monthlyIncome / requestedAmount): + * + * <ul> + * <li>≥ 0.50 (income covers loan in ≤ 2 months) → full points + * <li>≥ 0.25 (≤ 4 months) → 75% of max points + * <li>≥ 0.15 (≤ ~6.7 months) → 50% of max points + * <li>≥ 0.10 (≤ 10 months) → 25% of max points + * <li>< 0.10 → 0 points + * </ul> + * + * <p>If {@code requestedAmount} is null or zero, scores 0 (cannot evaluate an undefined loan size). + * If {@code monthlyIncome} is null or zero, scores 0 (no income means no repayment capacity). + */ +@Component +@RequiredArgsConstructor +public class IncomeLoanRatioFactor implements ScoringFactor { + + private static final String FACTOR_NAME = "income-ratio"; + + private final ScoringWeightsProperties weights; + + @Override + public FactorScore score(ApplicantScoringProfile profile) { + final int max = maxPoints(); + + BigDecimal income = profile.getMonthlyIncome(); + BigDecimal requested = profile.getRequestedAmount(); + + if (income == null + || requested == null + || income.compareTo(BigDecimal.ZERO) <= 0 + || requested.compareTo(BigDecimal.ZERO) <= 0) { + return FactorScore.builder() + .points(0) + .maxPoints(max) + .explanation("Insufficient data to evaluate income-to-loan ratio.") + .build(); + } + + // ratio = monthlyIncome / requestedAmount + BigDecimal ratio = income.divide(requested, 4, RoundingMode.HALF_UP); + double r = ratio.doubleValue(); + + int points; + String explanation; + + if (r >= 0.50) { Review Comment: Why hard code these what if a financial institution wants to configure their own rules? -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
