fix(closure): post interest for real at submission, not as a dry-run estimate
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Ticking "Close this account" used to only preview the payout: assemble the
account, run the same postInterest domain call close() will, read the
resulting balance, then roll the whole thing back (GET /preview/{id}).
Nothing was ever credited until the manager approved, and the account's own
Interest Posted / Interest Earned figures stayed stale in the meantime.
Changed to actually post the interest at that moment (POST
/post-interest/{id} and again in submit()), via
SavingsAccountWritePlatformService#postInterest(SavingsAccount, boolean,
LocalDate, boolean) -- the exact service method close() itself calls, not a
new calculation. Not routed through the POSTINTEREST_SAVINGSACCOUNT command:
that command ships maker-checker-enabled in base Fineract, and
CommandSourceService#processCommand runs the handler first, then throws
RollbackTransactionNotApprovedException from inside the @Transactional
method -- which would silently roll back the very posting just made.
Depends on the sibling SavingsAccount fix (double-posting across runs) --
without it, ticking the checkbox on one business day and having the manager
approve on a later one re-slices posting periods and credits the overlapping
days twice.
Also:
- Locks the closure date at submission (stored on the request row) and
replays that same date at approve() instead of re-reading today's business
date, so close()'s own postInterest(account, true, closedOnDate, false)
slices identical periods, matches the amounts already posted at submission,
and adds nothing -- the figure quoted to the member at submission is what
gets paid out however long the request sits in the manager's queue.
- Populates closing_amount/rounding_amount/cashier_disbursed_amount at
submission time instead of leaving them null until approval, since the
interest posting above already makes the real payout figure known then --
the manager's review screen no longer shows a blank amount for a PENDING
request.
This commit is contained in:
+126
-92
@@ -37,7 +37,6 @@ 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;
|
||||||
@@ -45,6 +44,7 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -53,13 +53,12 @@ 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;
|
||||||
@@ -67,10 +66,7 @@ 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
|
||||||
@@ -99,11 +95,15 @@ 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 compute the closing figure the way close() will actually compute it.
|
// Used by postClosureInterest(), invoked from both submit() and postInterestForClosure(), to
|
||||||
|
// assemble the account before posting real interest onto it.
|
||||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||||
private final ConfigurationDomainService configurationDomainService;
|
|
||||||
private final PlatformTransactionManager transactionManager;
|
|
||||||
private final VoucherService voucherService;
|
private final VoucherService voucherService;
|
||||||
|
// The interest posting that has always backed "Post Interest As On" and close() alike. Called as
|
||||||
|
// the service method rather than through the POSTINTEREST_SAVINGSACCOUNT command, whose
|
||||||
|
// maker-checker flag would roll the posting straight back out again (CommandSourceService runs
|
||||||
|
// the handler first, then throws RollbackTransactionNotApprovedException).
|
||||||
|
private final SavingsAccountWritePlatformService savingsAccountWritePlatformService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
// ── POST /v1/clerk/closure-requests ── Clerk submits new request ────────────
|
// ── POST /v1/clerk/closure-requests ── Clerk submits new request ────────────
|
||||||
@@ -154,64 +154,97 @@ public class ClerkClosureRequestApiResource {
|
|||||||
throw apiError(409, "This account already has an open closure request (pending or awaiting cashier).");
|
throw apiError(409, "This account already has an open closure request (pending or awaiting cashier).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The closure date is fixed HERE, at submission, and every later step reads it back off the row
|
||||||
|
// rather than re-reading the business date. That is what keeps the figure the clerk quoted the
|
||||||
|
// member identical to the figure the cashier hands over: closedOnDate drives which posting
|
||||||
|
// periods close() slices, so letting it float to the manager's approval day would recompute the
|
||||||
|
// interest against different period boundaries and pay out a different amount.
|
||||||
|
final LocalDate closureDate = DateUtils.getBusinessLocalDate();
|
||||||
|
|
||||||
|
// Post the interest for real, now -- the account's balance must already include everything the
|
||||||
|
// member has earned the moment the clerk raises the closure, not merely be estimated. This is
|
||||||
|
// the same service method "Post Interest As On" and close() both go through, invoked with the
|
||||||
|
// same arguments close() will use (postInterestAs = true, transactionDate = closureDate), so
|
||||||
|
// when the manager approves, close()'s own postInterest(account, true, closureDate, false) finds
|
||||||
|
// these very transactions at the very same period ends, matches their amounts, and adds nothing.
|
||||||
|
final BigDecimal closingAmount = postClosureInterest(savingsAccountId, closureDate);
|
||||||
|
// Rounded the same way approve() rounds it -- so the manager sees, while it is still PENDING,
|
||||||
|
// exactly what cash the cashier will end up handing over, not just the exact-to-the-paisa figure.
|
||||||
|
final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP);
|
||||||
|
final BigDecimal roundingAmount = cashierDisbursedAmount.subtract(closingAmount);
|
||||||
|
|
||||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||||
|
|
||||||
final Long newId = jdbcTemplate.queryForObject("""
|
final Long newId = jdbcTemplate.queryForObject("""
|
||||||
INSERT INTO m_clerk_closure_request
|
INSERT INTO m_clerk_closure_request
|
||||||
(savings_account_id, account_no, client_id, client_name, closure_date,
|
(savings_account_id, account_no, client_id, client_name, closure_date,
|
||||||
payment_type_id, payment_type_name, description, tok_no,
|
payment_type_id, payment_type_name, description, tok_no,
|
||||||
status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at)
|
status, closing_amount, rounding_amount, cashier_disbursed_amount,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
clerk_id, clerk_name, clerk_username, activity_log, submitted_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
Long.class,
|
Long.class,
|
||||||
savingsAccountId, account.get("account_no"), account.get("client_id"), account.get("client_name"),
|
savingsAccountId, account.get("account_no"), account.get("client_id"), account.get("client_name"),
|
||||||
Date.valueOf(DateUtils.getBusinessLocalDate()),
|
Date.valueOf(closureDate),
|
||||||
paymentTypeId, paymentTypeName, description, tokNo,
|
paymentTypeId, paymentTypeName, description, tokNo,
|
||||||
"PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
"PENDING", closingAmount, roundingAmount, cashierDisbursedAmount,
|
||||||
|
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||||
activityLog, Timestamp.from(Instant.now())
|
activityLog, Timestamp.from(Instant.now())
|
||||||
);
|
);
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("resourceId", newId);
|
||||||
|
response.put("status", "PENDING");
|
||||||
|
response.put("closureDate", closureDate.toString());
|
||||||
|
response.put("payableAmount", closingAmount);
|
||||||
|
response.put("roundedPayableAmount", cashierDisbursedAmount);
|
||||||
|
return gson.toJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ──────────────
|
/**
|
||||||
|
* Posts everything the member has earned up to the closure date, and returns the resulting real
|
||||||
|
* account balance -- the figure the closure will pay out.
|
||||||
|
*
|
||||||
|
* Deliberately not the CALCULATEINTEREST command and not a bespoke calculation: it is the very
|
||||||
|
* {@code postInterest(account, true, closureDate, false)} call that
|
||||||
|
* {@code SavingsAccountWritePlatformServiceJpaRepositoryImpl#close} makes, so the two agree by
|
||||||
|
* construction rather than by coincidence. Going through the service (not the entity directly)
|
||||||
|
* keeps the journal entries and the interest voucher that a posting is supposed to raise.
|
||||||
|
*/
|
||||||
|
private BigDecimal postClosureInterest(final Long savingsAccountId, final LocalDate closureDate) {
|
||||||
|
final SavingsAccount account = savingsAccountAssembler.assembleFrom(savingsAccountId, false);
|
||||||
|
savingsAccountWritePlatformService.postInterest(account, true, closureDate, false);
|
||||||
|
return zeroIfNull(account.getSummary().getAccountBalance());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /v1/clerk/closure-requests/post-interest/{savingsAccountId} ───────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "What would this member actually receive" preview, so a clerk sees the real closing figure
|
* Posts everything the member has earned up to the closure date and reports the account's real
|
||||||
* before submitting.
|
* resulting balance -- the figure the closure will pay out.
|
||||||
*
|
*
|
||||||
* This must reproduce what close() will pay out, to the paisa, and reading the stored
|
* A POST, not a GET, because it genuinely mutates: ticking "Close this account" is meant to put
|
||||||
* {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last
|
* the interest INTO the account, not to estimate it. A dry run used to stand here, and it read
|
||||||
* touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is
|
* correctly to the paisa, but it left the account's own balance behind the figure on screen and
|
||||||
* close but not identical -- it uses {@code MathContext(15)} and passes
|
* the clerk had no way to see that the member had actually been credited.
|
||||||
* {@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.
|
|
||||||
*
|
*
|
||||||
* So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method
|
* The posting itself is {@link #postClosureInterest}, i.e. the very
|
||||||
* close() ends up in with the same arguments, read the resulting balance -- which is exactly the
|
* {@code postInterest(account, true, closureDate, false)} that
|
||||||
* figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right
|
* {@code SavingsAccountWritePlatformServiceJpaRepositoryImpl#close} performs -- so approval later
|
||||||
* after posting interest) -- and then roll the whole thing back.
|
* finds these transactions at the identical period ends and adds nothing.
|
||||||
*
|
*
|
||||||
* Why this is safe to run per keystroke:
|
* Safe to tick and untick, and safe across days: SavingsAccount#postInterest reverses any manual
|
||||||
* <ul>
|
* posting this run's periods have orphaned before applying them, so a second run can no longer
|
||||||
* <li>The domain {@code postInterest} touches nothing outside the entity graph: no repository
|
* double-credit the days an earlier one already paid. Cancelling the popup or a manager rejecting
|
||||||
* save, no journal entry, no voucher, no event. All of that lives in the service layer, which is
|
* the request deliberately leaves the posting in place -- it is interest the member had genuinely
|
||||||
* deliberately bypassed here.</li>
|
* earned either way, and the next posting run reconciles it correctly.
|
||||||
* <li>REQUIRES_NEW, so marking this rollback-only cannot poison a caller's transaction, and the
|
|
||||||
* 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>
|
|
||||||
*/
|
*/
|
||||||
@GET
|
@POST
|
||||||
@Path("/preview/{savingsAccountId}")
|
@Path("/post-interest/{savingsAccountId}")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) {
|
@Transactional
|
||||||
|
public String postInterestForClosure(@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,
|
||||||
@@ -227,63 +260,39 @@ 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 -- the dry run below
|
// postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- posting through the
|
||||||
// would quietly compute the wrong number for them.
|
// plain savings path 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 int statusEnum = ((Number) account.get("status_enum")).intValue();
|
||||||
final BigDecimal storedBalance = new BigDecimal(account.get("balance").toString());
|
if (statusEnum != 300) {
|
||||||
// The CBS business date, not wall-clock: this is the date the closure will actually post on
|
throw apiError(409, "Only an ACTIVE account can be closed. Current status: " + statusEnum);
|
||||||
// (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.
|
|
||||||
final LocalDate closureDate = DateUtils.getBusinessLocalDate();
|
|
||||||
|
|
||||||
// Read configuration first -- see the flush note in the javadoc.
|
|
||||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = configurationDomainService
|
|
||||||
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
|
||||||
final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth();
|
|
||||||
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 balanceBefore = new BigDecimal(account.get("balance").toString());
|
||||||
final BigDecimal balance = computed ? figures[0] : storedBalance;
|
// The CBS business date, not wall-clock: submit() stamps this same date onto the request as
|
||||||
final BigDecimal total = computed ? figures[1] : storedBalance;
|
// closure_date, and approve() replays it as closedOnDate -- so it is the date the interest is
|
||||||
final BigDecimal interest = total.subtract(balance);
|
// calculated up to at every step. Using LocalDate.now() here would silently disagree around a
|
||||||
|
// day roll.
|
||||||
|
final LocalDate closureDate = DateUtils.getBusinessLocalDate();
|
||||||
|
|
||||||
|
final BigDecimal total = postClosureInterest(savingsAccountId, closureDate);
|
||||||
|
final BigDecimal interest = total.subtract(balanceBefore);
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
final JsonObject res = new JsonObject();
|
||||||
res.addProperty("savingsAccountId", savingsAccountId);
|
res.addProperty("savingsAccountId", savingsAccountId);
|
||||||
res.addProperty("accountNo", String.valueOf(account.get("account_no")));
|
res.addProperty("accountNo", String.valueOf(account.get("account_no")));
|
||||||
res.addProperty("clientName", account.get("client_name") == null ? "" : String.valueOf(account.get("client_name")));
|
res.addProperty("clientName", account.get("client_name") == null ? "" : String.valueOf(account.get("client_name")));
|
||||||
res.addProperty("statusEnum", ((Number) account.get("status_enum")).intValue());
|
res.addProperty("statusEnum", statusEnum);
|
||||||
res.addProperty("balance", balance);
|
res.addProperty("balance", balanceBefore);
|
||||||
res.addProperty("accruedInterest", interest);
|
res.addProperty("accruedInterest", interest);
|
||||||
res.addProperty("total", total);
|
res.addProperty("total", total);
|
||||||
// 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("interestUpToDate", true);
|
||||||
res.addProperty("asOfDate", closureDate.toString());
|
res.addProperty("asOfDate", closureDate.toString());
|
||||||
return gson.toJson(res);
|
return gson.toJson(res);
|
||||||
}
|
}
|
||||||
@@ -475,9 +484,14 @@ public class ClerkClosureRequestApiResource {
|
|||||||
throw apiError(409, "Account is no longer ACTIVE (status " + statusEnum + ") — cannot close.");
|
throw apiError(409, "Account is no longer ACTIVE (status " + statusEnum + ") — cannot close.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fineract's own close command: pro-rata interest, TDS and the real GL withdrawal all post
|
// Fineract's own close command, run against the date the closure was RAISED on, not today's.
|
||||||
// atomically here -- exactly as they would from the ordinary Close Savings Account screen.
|
// submit() already posted the interest up to that date; replaying the same closedOnDate here
|
||||||
executeClose(savingsAccountId);
|
// makes close()'s postInterest(account, true, closedOnDate, false) slice the identical posting
|
||||||
|
// periods, find those very transactions at the identical period ends, match their amounts and
|
||||||
|
// add nothing -- so the balance it withdraws is exactly the figure the clerk quoted, however
|
||||||
|
// many days the request sat in the manager's queue.
|
||||||
|
final LocalDate closureDate = toLocalDate(record.get("closure_date"));
|
||||||
|
executeClose(savingsAccountId, closureDate);
|
||||||
|
|
||||||
// Read back the withdrawal transaction close() just posted -- its CommandProcessingResult
|
// Read back the withdrawal transaction close() just posted -- its CommandProcessingResult
|
||||||
// doesn't return the transaction id/amount directly. transaction_type_enum 2 = WITHDRAWAL.
|
// doesn't return the transaction id/amount directly. transaction_type_enum 2 = WITHDRAWAL.
|
||||||
@@ -672,14 +686,34 @@ public class ClerkClosureRequestApiResource {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void executeClose(final Long savingsAccountId) {
|
/**
|
||||||
// Business date, matching what preview() computed the figure against.
|
* Reads a DATE column back out of a JDBC result map. The driver hands back {@link java.sql.Date}
|
||||||
final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
* today, but a {@link LocalDate} under a different driver or mapper configuration would otherwise
|
||||||
|
* blow up the whole approval on a ClassCastException, so both are accepted.
|
||||||
|
*/
|
||||||
|
private LocalDate toLocalDate(final Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
throw apiError(500, "Closure request is missing its closure date — cannot close the account.");
|
||||||
|
}
|
||||||
|
if (value instanceof LocalDate localDate) {
|
||||||
|
return localDate;
|
||||||
|
}
|
||||||
|
if (value instanceof Date sqlDate) {
|
||||||
|
return sqlDate.toLocalDate();
|
||||||
|
}
|
||||||
|
return LocalDate.parse(value.toString().substring(0, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeClose(final Long savingsAccountId, final LocalDate closureDate) {
|
||||||
|
// The date the closure was RAISED on, read off the request row -- never today's business date.
|
||||||
|
// See the call site: it is what keeps the amount quoted to the member and the amount paid out
|
||||||
|
// identical no matter how long approval takes.
|
||||||
|
final String closedOn = closureDate.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||||
|
|
||||||
final JsonObject payload = new JsonObject();
|
final JsonObject payload = new JsonObject();
|
||||||
payload.addProperty("locale", "en");
|
payload.addProperty("locale", "en");
|
||||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
payload.addProperty("closedOnDate", today);
|
payload.addProperty("closedOnDate", closedOn);
|
||||||
// Unconditional and never skippable -- closure always withdraws the full balance.
|
// Unconditional and never skippable -- closure always withdraws the full balance.
|
||||||
payload.addProperty("withdrawBalance", true);
|
payload.addProperty("withdrawBalance", true);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user