Revert "fix(savings,closure): post interest for real when a closure is checked"
This reverts commit c86e0d1cc2.
This commit is contained in:
+74
-39
@@ -37,6 +37,7 @@ import jakarta.ws.rs.WebApplicationException;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.math.MathContext;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.sql.Date;
|
import java.sql.Date;
|
||||||
import java.sql.Timestamp;
|
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.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
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.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
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.SavingsAccount;
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
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.VoucherLine;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
@@ -65,7 +67,10 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
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.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s
|
* Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s
|
||||||
@@ -94,9 +99,10 @@ public class ClerkClosureRequestApiResource {
|
|||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
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 SavingsAccountAssembler savingsAccountAssembler;
|
||||||
private final SavingsAccountWritePlatformService savingsAccountWritePlatformService;
|
private final ConfigurationDomainService configurationDomainService;
|
||||||
|
private final PlatformTransactionManager transactionManager;
|
||||||
private final VoucherService voucherService;
|
private final VoucherService voucherService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
@@ -169,45 +175,47 @@ public class ClerkClosureRequestApiResource {
|
|||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
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
|
* "What would this member actually receive" preview, so a clerk sees the real closing figure
|
||||||
* ({@code postInterest(account, true, businessDate, false)}) -- and reports back the resulting
|
* before submitting.
|
||||||
* balance, so the clerk sees the exact figure the account will close with, already credited.
|
|
||||||
*
|
*
|
||||||
* This is a genuine posting, not an estimate. It has to be: a dry run reproducing close()'s own
|
* This must reproduce what close() will pay out, to the paisa, and reading the stored
|
||||||
* MathContext(10) and postInterestOnDate was tried first, but calling
|
* {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last
|
||||||
* {@link org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService
|
* touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is
|
||||||
* #postInterest(SavingsAccount, boolean, LocalDate, boolean)} directly -- the exact method
|
* close but not identical -- it uses {@code MathContext(15)} and passes
|
||||||
* close() itself calls -- makes the account's own real balance the source of truth instead, and
|
* {@code postInterestOnDate = null}, whereas close() calls
|
||||||
* removes an entire class of "did the simulation really match reality" risk.
|
* {@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
|
* So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method
|
||||||
* pipeline: that permission ({@code POSTINTEREST_SAVINGSACCOUNT}) ships maker-checker-enabled in
|
* close() ends up in with the same arguments, read the resulting balance -- which is exactly the
|
||||||
* base Fineract, and a Clerk is not a checker for it. Routed through
|
* figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right
|
||||||
* {@code commandsSourceWritePlatformService} it would silently roll back and land in Fineract's
|
* after posting interest) -- and then roll the whole thing back.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Posting now and closing later is safe to leave uncommitted-to: if the clerk unticks the box or
|
* Why this is safe to run per keystroke:
|
||||||
* the request is rejected, the interest already posted is simply real interest the member had
|
* <ul>
|
||||||
* genuinely earned -- crediting it is correct with or without the closure going through, and
|
* <li>The domain {@code postInterest} touches nothing outside the entity graph: no repository
|
||||||
* re-ticking later just finds the same day's posting already there and no-ops (see
|
* save, no journal entry, no voucher, no event. All of that lives in the service layer, which is
|
||||||
* SavingsAccount#postInterest's own correction check). If manager approval lands on a later
|
* deliberately bypassed here.</li>
|
||||||
* business date, close() posts the extra days on top; it never re-posts what is already there
|
* <li>REQUIRES_NEW, so marking this rollback-only cannot poison a caller's transaction, and the
|
||||||
* for a date that matches.
|
* dirtied entities live in a separate persistence context that is discarded.</li>
|
||||||
|
* <li>Config is read before the account is assembled: a cache-miss JPA query afterwards could
|
||||||
|
* auto-flush the in-memory interest transactions, and while the rows would roll back, the
|
||||||
|
* identity-sequence values they consumed would not.</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@POST
|
@GET
|
||||||
@Path("/preview/{savingsAccountId}")
|
@Path("/preview/{savingsAccountId}")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) {
|
public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT sa.account_no, sa.status_enum, sa.deposit_type_enum,
|
SELECT sa.account_no, sa.status_enum, sa.deposit_type_enum,
|
||||||
COALESCE(c.display_name, g.display_name) AS client_name
|
COALESCE(c.display_name, g.display_name) AS client_name,
|
||||||
|
COALESCE(sa.account_balance_derived, 0) AS balance
|
||||||
FROM m_savings_account sa
|
FROM m_savings_account sa
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
@@ -219,23 +227,49 @@ public class ClerkClosureRequestApiResource {
|
|||||||
final Map<String, Object> account = rows.get(0);
|
final Map<String, Object> account = rows.get(0);
|
||||||
// Savings only (deposit_type_enum 100). An FD/RD assembles as a FixedDepositAccount /
|
// Savings only (deposit_type_enum 100). An FD/RD assembles as a FixedDepositAccount /
|
||||||
// RecurringDepositAccount, whose interest and tax on closure run through
|
// RecurringDepositAccount, whose interest and tax on closure run through
|
||||||
// postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- calling the plain
|
// postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- the dry run below
|
||||||
// savings postInterest on one of those would post the wrong figure.
|
// would quietly compute the wrong number for them.
|
||||||
final int depositType = ((Number) account.get("deposit_type_enum")).intValue();
|
final int depositType = ((Number) account.get("deposit_type_enum")).intValue();
|
||||||
if (depositType != 100) {
|
if (depositType != 100) {
|
||||||
throw apiError(400, "This closure flow supports savings accounts only (deposit type " + depositType + ").");
|
throw apiError(400, "This closure flow supports savings accounts only (deposit type " + depositType + ").");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final BigDecimal storedBalance = new BigDecimal(account.get("balance").toString());
|
||||||
// The CBS business date, not wall-clock: this is the date the closure will actually post on
|
// The CBS business date, not wall-clock: this is the date the closure will actually post on
|
||||||
// (executeClose sends it as closedOnDate) and the date close() itself would post interest up
|
// (executeClose sends it as closedOnDate) and the date close() itself calculates interest up
|
||||||
// to. Using LocalDate.now() here would silently disagree with approval around a day roll.
|
// to. Using LocalDate.now() here would silently disagree with approval around a day roll.
|
||||||
final LocalDate closureDate = DateUtils.getBusinessLocalDate();
|
final LocalDate closureDate = DateUtils.getBusinessLocalDate();
|
||||||
|
|
||||||
final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false);
|
// Read configuration first -- see the flush note in the javadoc.
|
||||||
final BigDecimal balance = zeroIfNull(acct.getSummary().getAccountBalance());
|
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = configurationDomainService
|
||||||
// The exact call close() makes: postInterest(account, true, closedDate, false).
|
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||||
savingsAccountWritePlatformService.postInterest(acct, true, closureDate, false);
|
final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||||
final BigDecimal total = zeroIfNull(acct.getSummary().getAccountBalance());
|
final MathContext mc = new MathContext(10, MoneyHelper.getRoundingMode());
|
||||||
|
|
||||||
|
final TransactionTemplate dryRun = new TransactionTemplate(transactionManager);
|
||||||
|
dryRun.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||||
|
|
||||||
|
BigDecimal[] figures = null;
|
||||||
|
try {
|
||||||
|
figures = dryRun.execute(status -> {
|
||||||
|
final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false);
|
||||||
|
final BigDecimal before = zeroIfNull(acct.getSummary().getAccountBalance());
|
||||||
|
// The exact call close() makes, via postInterest(account, true, closedDate, false).
|
||||||
|
acct.postInterest(mc, closureDate, false, isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||||
|
financialYearBeginningMonth, closureDate, false, false);
|
||||||
|
final BigDecimal after = zeroIfNull(acct.getSummary().getAccountBalance());
|
||||||
|
status.setRollbackOnly();
|
||||||
|
return new BigDecimal[] { before, after };
|
||||||
|
});
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// Never cost the clerk the preview entirely -- fall through to the stored balance and
|
||||||
|
// flag that the interest figure could not be computed.
|
||||||
|
figures = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean computed = figures != null;
|
||||||
|
final BigDecimal balance = computed ? figures[0] : storedBalance;
|
||||||
|
final BigDecimal total = computed ? figures[1] : storedBalance;
|
||||||
final BigDecimal interest = total.subtract(balance);
|
final BigDecimal interest = total.subtract(balance);
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
final JsonObject res = new JsonObject();
|
||||||
@@ -249,6 +283,7 @@ public class ClerkClosureRequestApiResource {
|
|||||||
// What a cashier could physically count out: no paisa coins circulate, so the hand-over is
|
// What a cashier could physically count out: no paisa coins circulate, so the hand-over is
|
||||||
// always whole rupees (the difference lands on the Round Off Account at approval).
|
// always whole rupees (the difference lands on the Round Off Account at approval).
|
||||||
res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP));
|
res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP));
|
||||||
|
res.addProperty("interestUpToDate", computed);
|
||||||
res.addProperty("asOfDate", closureDate.toString());
|
res.addProperty("asOfDate", closureDate.toString());
|
||||||
return gson.toJson(res);
|
return gson.toJson(res);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user