adamsaghy commented on code in PR #5580:
URL: https://github.com/apache/fineract/pull/5580#discussion_r2930574539


##########
fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/calc/ProjectedAmortizationScheduleModel.java:
##########
@@ -0,0 +1,382 @@
+/**
+ * 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.workingcapitalloanproduct.calc;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.IntStream;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+import org.apache.fineract.infrastructure.core.serialization.gson.JsonExclude;
+
+/**
+ * Projected Amortization Schedule model for Working Capital loans.
+ *
+ * <h3>Lifecycle</h3>
+ * <ol>
+ * <li>{@link #generate} — create initial schedule (at loan creation)</li>
+ * <li>{@link #regenerate} — recalculate with new amounts (at approval / 
disbursement)</li>
+ * <li>{@link #applyPayment} — record payments by date; schedule rebuilds 
after each</li>
+ * </ol>
+ *
+ * <p>
+ * TODO: migrate monetary fields to Money wrapper
+ */
+@Getter
+@Accessors(fluent = true)
+public final class ProjectedAmortizationScheduleModel {
+
+    private static final String MODEL_VERSION = "1";
+
+    private final BigDecimal discountAmount;
+    private final BigDecimal netAmount;
+    private final BigDecimal totalPaymentValue;
+    private final BigDecimal periodPaymentRate;
+    private final int npvDayCount;
+    private final LocalDate startDate;
+
+    /** {@code (TPV × periodPaymentRate) / npvDayCount} — constant across 
installments. */
+    private final BigDecimal expectedPaymentAmount;
+
+    /** {@code round((netAmount + discountAmount) / expectedPaymentAmount)} */
+    private final int loanTerm;
+
+    /** Periodic EIR from {@code RATE(loanTerm, -expectedPayment, netAmount)}. 
*/
+    private final BigDecimal effectiveInterestRate;
+
+    @JsonExclude
+    private final MathContext mc;
+
+    @Getter(AccessLevel.NONE)
+    private final List<AppliedPayment> appliedPayments;
+
+    @Getter(AccessLevel.NONE)
+    private List<ProjectedAmortizationInstallment> installments;
+
+    private ProjectedAmortizationScheduleModel(final BigDecimal 
discountAmount, final BigDecimal netAmount,
+            final BigDecimal totalPaymentValue, final BigDecimal 
periodPaymentRate, final int npvDayCount, final LocalDate startDate,
+            final BigDecimal expectedPaymentAmount, final int loanTerm, final 
BigDecimal effectiveInterestRate, final MathContext mc) {
+        this.discountAmount = discountAmount;
+        this.netAmount = netAmount;
+        this.totalPaymentValue = totalPaymentValue;
+        this.periodPaymentRate = periodPaymentRate;
+        this.npvDayCount = npvDayCount;
+        this.startDate = startDate;
+        this.expectedPaymentAmount = expectedPaymentAmount;
+        this.loanTerm = loanTerm;
+        this.effectiveInterestRate = effectiveInterestRate;
+        this.mc = mc;
+        this.appliedPayments = new ArrayList<>();
+        rebuildInstallments();
+    }
+
+    /**
+     * Creates a skeleton instance for Gson deserialization. Gson will 
overwrite final fields via reflection;
+     * installments are restored from JSON directly (no rebuild needed).
+     */
+    public static ProjectedAmortizationScheduleModel forDeserialization(final 
MathContext mc) {
+        return new ProjectedAmortizationScheduleModel(mc);
+    }
+
+    private ProjectedAmortizationScheduleModel(final MathContext mc) {
+        this.discountAmount = null;
+        this.netAmount = null;
+        this.totalPaymentValue = null;
+        this.periodPaymentRate = null;
+        this.npvDayCount = 0;
+        this.startDate = null;
+        this.expectedPaymentAmount = null;
+        this.loanTerm = 0;
+        this.effectiveInterestRate = null;
+        this.mc = mc;
+        this.appliedPayments = new ArrayList<>();
+        this.installments = List.of();
+    }
+
+    public List<ProjectedAmortizationInstallment> installments() {
+        return installments;
+    }
+
+    public static ProjectedAmortizationScheduleModel generate(final BigDecimal 
discountAmount, final BigDecimal netAmount,
+            final BigDecimal totalPaymentValue, final BigDecimal 
periodPaymentRate, final int npvDayCount, final LocalDate startDate,
+            final MathContext mc) {
+
+        Objects.requireNonNull(discountAmount, "discountAmount");
+        Objects.requireNonNull(netAmount, "netAmount");
+        Objects.requireNonNull(totalPaymentValue, "totalPaymentValue");
+        Objects.requireNonNull(periodPaymentRate, "periodPaymentRate");
+        Objects.requireNonNull(startDate, "startDate");
+        if (netAmount.signum() <= 0) {
+            throw new IllegalArgumentException("netAmount must be positive");
+        }
+        if (npvDayCount <= 0) {
+            throw new IllegalArgumentException("npvDayCount must be positive");
+        }
+
+        final BigDecimal expectedPayment = 
totalPaymentValue.multiply(periodPaymentRate, 
mc).divide(BigDecimal.valueOf(npvDayCount), mc);
+        if (expectedPayment.signum() <= 0) {
+            throw new IllegalArgumentException("expectedPaymentAmount must be 
positive (check totalPaymentValue and periodPaymentRate)");
+        }
+
+        final int term = netAmount.add(discountAmount, 
mc).divide(expectedPayment, mc).setScale(0, 
RoundingMode.HALF_UP).intValueExact();
+        if (term <= 0) {
+            throw new IllegalArgumentException("computed loan term must be 
positive, got: " + term);
+        }
+
+        final BigDecimal eir = TvmFunctions.rate(term, 
expectedPayment.negate(), netAmount, mc);
+
+        return new ProjectedAmortizationScheduleModel(discountAmount, 
netAmount, totalPaymentValue, periodPaymentRate, npvDayCount,
+                startDate, expectedPayment, term, eir, mc);
+    }
+
+    public void applyPayment(final LocalDate paymentDate, final BigDecimal 
amount) {
+        Objects.requireNonNull(paymentDate, "paymentDate");
+        Objects.requireNonNull(amount, "amount");
+        final int index = resolveInstallmentIndex(paymentDate);
+        if (index < 0 || index >= loanTerm) {
+            throw new IllegalArgumentException("paymentDate " + paymentDate + 
" does not map to a valid installment [1.." + loanTerm + "]");
+        }
+        appliedPayments.add(new AppliedPayment(paymentDate, amount));
+        rebuildInstallments();
+    }
+
+    /** Creates a new model with updated parameters, preserving applied 
payments. */
+    public ProjectedAmortizationScheduleModel regenerate(final BigDecimal 
newDiscountAmount, final BigDecimal newNetAmount,
+            final LocalDate newStartDate) {
+        final ProjectedAmortizationScheduleModel newModel = 
generate(newDiscountAmount, newNetAmount, totalPaymentValue, periodPaymentRate,
+                npvDayCount, newStartDate, mc);
+        newModel.appliedPayments.addAll(appliedPayments);
+        newModel.rebuildInstallments();
+        return newModel;
+    }
+
+    private void rebuildInstallments() {
+        this.installments = List.copyOf(buildInstallments(buildPaymentList()));
+    }
+
+    private List<BigDecimal> buildPaymentList() {
+        final List<BigDecimal> result = new 
ArrayList<>(Collections.nCopies(loanTerm, null));
+        for (final AppliedPayment payment : appliedPayments) {
+            final int index = resolveInstallmentIndex(payment.date());
+            if (index >= 0 && index < loanTerm) {
+                result.set(index, payment.amount());
+            }
+        }
+        return result;
+    }
+
+    private int resolveInstallmentIndex(final LocalDate date) {
+        return (int) ChronoUnit.DAYS.between(startDate, date) - 1;
+    }
+
+    private List<ProjectedAmortizationInstallment> buildInstallments(final 
List<BigDecimal> payments) {
+        final BalancesAndAmortizations ba = computeBalancesAndAmortizations();
+        final int paidCount = countConsecutivePaidPeriods(payments);

Review Comment:
   Any thoughts on this? I dont see any value of paidCount... there is no such 
thing "paid"...  we just modify the projection how much (aggregated) was paid 
on a particular date.



-- 
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]

Reply via email to