diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index c8f2e453d..4d3d6f201 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -37,6 +37,7 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.math.BigDecimal; +import java.math.MathContext; import java.math.RoundingMode; import java.sql.Date; import java.sql.Timestamp; @@ -52,12 +53,13 @@ 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.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; -import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService; import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; @@ -65,7 +67,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; /** * Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s @@ -94,9 +99,10 @@ public class ClerkClosureRequestApiResource { private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - // Used only by preview(), to post interest for real the same way close() eventually will. + // Used only by preview(), to compute the closing figure the way close() will actually compute it. private final SavingsAccountAssembler savingsAccountAssembler; - private final SavingsAccountWritePlatformService savingsAccountWritePlatformService; + private final ConfigurationDomainService configurationDomainService; + private final PlatformTransactionManager transactionManager; private final VoucherService voucherService; private final Gson gson = new GsonBuilder().serializeNulls().create(); @@ -169,45 +175,47 @@ public class ClerkClosureRequestApiResource { return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); } - // ── POST /v1/clerk/closure-requests/preview/{savingsAccountId} ───────────── + // ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ────────────── /** - * Posts interest to today for real -- the same domain call close() makes - * ({@code postInterest(account, true, businessDate, false)}) -- and reports back the resulting - * balance, so the clerk sees the exact figure the account will close with, already credited. + * "What would this member actually receive" preview, so a clerk sees the real closing figure + * before submitting. * - * This is a genuine posting, not an estimate. It has to be: a dry run reproducing close()'s own - * MathContext(10) and postInterestOnDate was tried first, but calling - * {@link org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService - * #postInterest(SavingsAccount, boolean, LocalDate, boolean)} directly -- the exact method - * close() itself calls -- makes the account's own real balance the source of truth instead, and - * removes an entire class of "did the simulation really match reality" risk. + * This must reproduce what close() will pay out, to the paisa, and reading the stored + * {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last + * touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is + * close but not identical -- it uses {@code MathContext(15)} and passes + * {@code postInterestOnDate = null}, whereas close() calls + * {@code postInterest(account, true, closedDate, false)}, which uses {@code MathContext(10)} and + * feeds {@code closedDate} in as an extra posting-period boundary + * (SavingsAccount#calculateInterestUsing adds it to postedAsOnDates). Splitting the final period + * changes how many per-period roundings happen, so the two can disagree by paise. * - * Called directly on the write-platform service, not through the ordinary POSTINTEREST command - * pipeline: that permission ({@code POSTINTEREST_SAVINGSACCOUNT}) ships maker-checker-enabled in - * base Fineract, and a Clerk is not a checker for it. Routed through - * {@code commandsSourceWritePlatformService} it would silently roll back and land in Fineract's - * own generic Checker Inbox -- a second, unrelated approval queue on top of this one. This - * feature already has its own maker-checker (submit here, approve in Closure Requests), so the - * interest posting rides inside that instead: real the moment the clerk ticks the box, same as - * "Post Interest As On" always was, with the actual approval gate being who signs off on the - * closure itself. + * So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method + * close() ends up in with the same arguments, read the resulting balance -- which is exactly the + * figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right + * after posting interest) -- and then roll the whole thing back. * - * Posting now and closing later is safe to leave uncommitted-to: if the clerk unticks the box or - * the request is rejected, the interest already posted is simply real interest the member had - * genuinely earned -- crediting it is correct with or without the closure going through, and - * re-ticking later just finds the same day's posting already there and no-ops (see - * SavingsAccount#postInterest's own correction check). If manager approval lands on a later - * business date, close() posts the extra days on top; it never re-posts what is already there - * for a date that matches. + * Why this is safe to run per keystroke: + *