Aman-Mittal commented on code in PR #204:
URL: 
https://github.com/apache/fineract-consumer-facing/pull/204#discussion_r4293020453


##########
consumer/src/main/java/org/apache/fineract/consumer/transfers/command/service/TransfersCommandServiceImpl.java:
##########
@@ -213,6 +219,20 @@ private void requireDestinationAllowed(Jwt jwt, Long 
userId, Long toAccountId, b
         }
     }
 
+    private void requireWithinOutstanding(Jwt jwt, Long loanId, BigDecimal 
amount) {
+        LoanAccountQueryData loan = loansQueryService.getLoan(jwt, loanId);

Review Comment:
   **Non-functional: this call is much heavier than the check needs.** 
`LoansQueryServiceImpl.getLoan`:
   - calls `retrieveOneLoan(loanId, false, "repaymentSchedule,transactions", 
...)`, so the full schedule and transaction history are fetched and mapped just 
to read `active` and `totalOutstanding`. On a long-running loan that's a large 
payload, and it happens twice per transfer (once in `initiate`, once in 
`confirm`);
   - runs `authorize(LOANS_VIEW, ...)` again, right after the `ownsResource` 
check on L203;
   - throws `loans.query` exceptions (`LoanQueryAccessDeniedException`, the 
loan-not-found and upstream exceptions). These come back from `/transfers/*` 
with `error.msg.consumer.loans...` codes instead of transfer codes;
   - adds a `transfers.command → loans.query` module dependency. Modulith 
allows it, but this is the only reason for it.
   
   A narrow lookup that returns only status and the payoff amount, without 
associations, would avoid all four problems. It also fits the refactor I 
suggested in reply to your architecture question, so the new code goes in the 
infrastructure lookup rather than a dependency on `LoansQueryService`.



##########
consumer/src/main/java/org/apache/fineract/consumer/transfers/command/service/TransfersCommandServiceImpl.java:
##########
@@ -213,6 +219,20 @@ private void requireDestinationAllowed(Jwt jwt, Long 
userId, Long toAccountId, b
         }
     }
 
+    private void requireWithinOutstanding(Jwt jwt, Long loanId, BigDecimal 
amount) {
+        LoanAccountQueryData loan = loansQueryService.getLoan(jwt, loanId);
+        if (loan == null || !loan.isActive()) {
+            throw new TransferLoanNotActiveException();
+        }
+        BigDecimal outstanding = loan.getTotalOutstanding();
+        if (outstanding == null || outstanding.signum() <= 0) {
+            throw new TransferLoanNotActiveException();
+        }
+        if (amount.compareTo(outstanding) > 0) {

Review Comment:
   **Correctness: `totalOutstanding` is the wrong cap for some products.** You 
flagged this in the PR description, and I think it needs fixing before merge 
rather than leaving as a known gap.
   
   For loans with interest recalculation (declining balance, progressive 
schedules), `summary.totalOutstanding` includes interest for **all future 
periods**. If the customer pays exactly `totalOutstanding` today, Fineract 
recalculates interest as of the repayment date, the unearned interest goes 
away, and the loan ends up **overpaid**. That is the exact outcome #114 is 
trying to prevent, and those are the products customers most often pay off 
early.
   
   Fineract has an endpoint for this: `GET 
/loans/{loanId}/transactions/template?command=prepayLoan` returns the amount 
needed to close the loan **as of today**. Capping at that value works for flat 
and recalculating products alike. For products without recalculation the two 
amounts are the same, so nothing changes for the cases that already work.
   
   At minimum, please add a unit test for each product type, plus a comment 
explaining why the chosen field is correct.



##########
docs/consumer/features/transfers.adoc:
##########
@@ -144,5 +144,5 @@ The query side owns transfer history because it is a 
cross-account view: per-acc
 
 * *Transfers execute immediately.* They use the server's current date; there 
is no scheduling, no future-dated transfer, and no recurring transfer.
 * *The transfer description is a fixed constant.* The customer cannot attach a 
note.
-* *No amount ceiling on own-account transfers.* Transfers to the customer's 
own accounts are bounded only by the optional per-beneficiary `transferLimit`. 
A global or per-tier transaction limit would need new policy data.
+* *No amount ceiling on own-savings transfers.* Transfers between the 
customer's own savings accounts are bounded only by the optional 
per-beneficiary `transferLimit` (loan repayments are capped at the loan's 
outstanding balance). A global or per-tier transaction limit would need new 
policy data.

Review Comment:
   **E2E coverage.** The checklist says e2e coverage was added where relevant, 
but `transfers.feature` has no scenario for the new rule. Two cheap scenarios 
against the real Fineract stack would cover it:
   - `Initiating a loan repayment above the outstanding balance is rejected`: 
the seeded loan principal is 10,000, so initiate 10,001 and expect 422 with 
`error.msg.consumer.transfers.command.loan.overpayment`. No OTP is sent and no 
money moves.
   - `Repaying exactly the outstanding balance is accepted`: this proves the 
boundary against real Fineract numbers. The savings account would need funding 
up to the outstanding amount, or use a smaller seeded loan.
   
   This also tests the `totalOutstanding` mapping against real Fineract data, 
which the mocked unit tests can't do.
   
   Also, the `confirm`-time check isn't atomic with the transfer. Could you add 
a sentence here saying the cap is a best-effort UX guard and not a guarantee? 
(See my comment on `confirm`.)



##########
consumer/src/main/java/org/apache/fineract/consumer/transfers/command/service/TransfersCommandServiceImpl.java:
##########
@@ -70,6 +74,7 @@ public class TransfersCommandServiceImpl implements 
TransfersCommandService {
     private final UserQueryService userQueryService;
     private final AccessPolicyEvaluator accessPolicyEvaluator;
     private final BeneficiariesQueryService beneficiariesQueryService;
+    private final LoansQueryService loansQueryService;

Review Comment:
   This new constructor dependency is what trips 
`featuresDoNotCallFineractFeignClientsDirectly`. The store records the whole 
constructor signature, so the existing violation lines for this class no longer 
match. The architecture question is answered in a separate comment. In short: 
please don't edit the store or switch to setter injection; do the preparatory 
refactor.



##########
frontend/public/i18n/en.json:
##########
@@ -570,5 +570,7 @@
   "error.msg.consumer.openbanking.consent.invalid": "This consent does not 
permit that request.",
   "error.msg.consumer.openbanking.account.not.found": "Account not found.",
   "error.msg.consumer.authentication.kyc.revoked": "Your identity verification 
is no longer valid. Please contact support.",
-  "error.msg.consumer.summary.upstream.unavailable": "Account summary is 
temporarily unavailable."
+  "error.msg.consumer.summary.upstream.unavailable": "Account summary is 
temporarily unavailable.",
+  "error.msg.consumer.transfers.command.loan.overpayment": "The amount exceeds 
your loan's outstanding balance.",
+  "error.msg.consumer.transfers.command.loan.not.active": "This loan has no 
outstanding balance to repay."

Review Comment:
   `TransferLoanNotActiveException` is thrown both for `!loan.isActive()` 
(submitted, approved but not disbursed, closed, written off) and for a zero 
balance. "This loan has no outstanding balance to repay." is wrong for a loan 
that is pending approval or not yet disbursed.
   
   Either reword it to cover both cases (e.g. "This loan can't receive a 
repayment right now."), or split it into two codes (`loan.not.active` and 
`loan.fully.repaid`). The same applies to the Hindi string and to the 
exception's message: "Loan is not active or has no outstanding balance." is 
fine as a developer message.



##########
consumer/src/main/java/org/apache/fineract/consumer/transfers/command/service/TransfersCommandServiceImpl.java:
##########
@@ -198,6 +203,7 @@ private void requireDestinationAllowed(Jwt jwt, Long 
userId, Long toAccountId, b
             if (!accessPolicyEvaluator.ownsResource(jwt, ResourceType.LOANS, 
toAccountId)) {
                 throw new TransferAccessDeniedException();
             }
+            requireWithinOutstanding(jwt, toAccountId, amount);

Review Comment:
   **Idempotency / retry behavior.** `confirm` calls this check (L138) before 
it sets the idempotency key (L141), so the check runs before Fineract can 
replay the original result. Consider the case where the customer pays the full 
balance and the response is lost on a timeout:
   - The client retries with the same `Idempotency-Key`. Because the OTP was 
consumed (`OtpServiceImpl` deletes it on success), the retry must go through 
`initiate` again.
   - `initiate` now throws `TransferLoanNotActiveException` (the loan is 
closed). For a partial payment where `amount > remaining`, it throws 
`TransferLoanOverpaymentException` instead.
   
   So the customer is told their payment was rejected when it actually 
succeeded. Before this PR the retry reached Fineract, which replayed the 
original response.
   
   This isn't a blocker, since the check at `initiate` is the main point of the 
PR. But please:
   1. document this in `transfers.adoc` (the check is best-effort and not 
atomic with the transfer: two confirms with different keys can each pass 
against the same balance, and Fineract would accept both), and
   2. consider whether the `confirm`-time check should only reject inactive 
loans and leave the amount check to `initiate`. That keeps the 
time-of-check/time-of-use gap the same, since the check was never atomic, and 
makes retries less surprising.



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