Compare commits

..
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 6cfa7ef3d1 feat(approvals): live Approvals badge counts over one SSE stream
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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (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-5) (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 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 E2E Tests / E2E Tests (Shard 1 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 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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (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-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
One endpoint now answers how many requests wait on every Approval Center tab,
so the screen no longer makes a call per tab.

- GET /v1/approvals/counts: snapshot, numbers only (held 3s, fresh=true skips).
- GET /v1/approvals/counts/stream: Server-Sent Events. A `counts` event is
  written the moment the numbers change; one shared 1s check serves every open
  screen and callers counted alike share one query. Keep-alive ping every 15s,
  stream recycled every 5 minutes.
- All 26 tabs are counted by one statement of scalar sub-selects, each the same
  table/status/office scope/exclusions as the tab's own API. A tab the caller may
  not see is not asked (permissions mirrored, disbursement for managers only).
- Layered: thin API resource, ApprovalCountsReadPlatformService (+Impl),
  ApprovalCountsStreamService, ApprovalCounterRegistry (one line per tab).
- Adds jersey-media-sse (SseEventSink / Sse).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:49:14 +05:30
16 changed files with 558 additions and 362 deletions
+2
View File
@@ -81,6 +81,8 @@ dependencies {
'org.apache.avro:avro', 'org.apache.avro:avro',
'jakarta.ws.rs:jakarta.ws.rs-api', 'jakarta.ws.rs:jakarta.ws.rs-api',
'org.glassfish.jersey.media:jersey-media-multipart', 'org.glassfish.jersey.media:jersey-media-multipart',
// Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one.
'org.glassfish.jersey.media:jersey-media-sse',
'org.springframework:spring-jms', 'org.springframework:spring-jms',
'org.springframework:spring-context-support', 'org.springframework:spring-context-support',
@@ -0,0 +1,67 @@
package org.apache.fineract.portfolio.approvals.api;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.CacheControl;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService;
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu.
* Numbers only: opening a tab still loads that tab's own list. The counting is in
* {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}.
*/
@Path("/v1/approvals/counts")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ApprovalCountsApiResource {
private final PlatformSecurityContext context;
private final ApprovalCountsReadPlatformService readService;
private final ApprovalCountsStreamService streamService;
/**
* The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held
* for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback.
*/
@GET
public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) {
final AppUser user = this.context.authenticatedUser();
final CacheControl noStore = new CacheControl();
noStore.setNoStore(true);
return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build();
}
/**
* Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change,
* so a screen neither polls nor reloads. The first event carries the numbers as they stand.
*/
@GET
@Path("stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) {
// Ask reverse proxies not to hold the events back.
response.setHeader("X-Accel-Buffering", "no");
response.setHeader("Cache-Control", "no-cache, no-store");
try {
this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse);
} catch (final RuntimeException e) {
sink.close();
throw e;
}
}
}
@@ -0,0 +1,14 @@
package org.apache.fineract.portfolio.approvals.data;
/**
* One Approval Center tab and the question that counts what waits on it.
*
* @param key
* the tab's route segment, which is also the key its badge reads on the screen
* @param permission
* the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees
* the tab
* @param sql
* a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan}
*/
public record ApprovalCounter(String key, String permission, String sql) {}
@@ -0,0 +1,27 @@
package org.apache.fineract.portfolio.approvals.data;
import com.google.gson.Gson;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal
* exactly when the numbers are, which is how a change is told from a repeat.
*/
public record ApprovalCountsData(long total, Map<String, Long> counts, String version) {
private static final Gson GSON = new Gson();
public static ApprovalCountsData of(final Map<String, Long> counts) {
final long total = counts.values().stream().mapToLong(Long::longValue).sum();
return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode()));
}
public String toJson() {
final Map<String, Object> json = new LinkedHashMap<>();
json.put("total", this.total);
json.put("counts", this.counts);
json.put("version", this.version);
return GSON.toJson(json);
}
}
@@ -0,0 +1,22 @@
package org.apache.fineract.portfolio.approvals.data;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
/**
* What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those
* tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then
* be used from any thread.
*
* @param tenant
* the tenant the counts are read from
* @param visible
* the tabs this caller may see, in the order the Approval Center shows them
* @param params
* the named parameters the tabs' queries use
* @param group
* equal for callers who are counted identically, so they can share one query
*/
public record ApprovalCountsPlan(FineractPlatformTenant tenant, List<ApprovalCounter> visible, MapSqlParameterSource params,
String group) {}
@@ -0,0 +1,122 @@
package org.apache.fineract.portfolio.approvals.service;
import java.util.List;
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
/**
* Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the
* one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new
* tab, add one line here and one badge on the screen.
*
* The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head
* office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command).
*/
public final class ApprovalCounterRegistry {
private ApprovalCounterRegistry() {}
/** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */
private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'";
/** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */
private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'";
/** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */
private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'";
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
+ "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id "
+ "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid "
+ "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name "
+ "AND p.entity_name = aud.entity_name)) ";
/** Where the person who raised a loan or account sits, for the office scope the tab applies. */
private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) "
+ "AND o.hierarchy LIKE :h)";
/** The one tab whose list is open to managers and head cashiers only. */
public static final String DISBURSEMENT_REQUESTS = "disbursement-requests";
/** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */
public static final List<ApprovalCounter> ALL = List.of(
// ── Fineract's own maker-checker and pending lists ────────────────────────────
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
new ApprovalCounter("client-approval", "READ_CLIENT",
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
new ApprovalCounter("loan-approval", "READ_LOAN",
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
+ "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE),
new ApprovalCounter("loan-disbursal", "READ_LOAN",
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
+ "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE),
new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN",
"SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"),
new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id "
+ "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 "
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
+ "AND COALESCE(ca.terminate_true, false) = false "
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
+ "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"),
new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"),
new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"),
// ── Requests raised by clerks, cashiers and managers ──────────────────────────
new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"),
new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"),
new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"),
new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"),
new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id "
+ "WHERE p.status = 100"),
new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id "
+ "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id "
+ "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"),
new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"),
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
new ApprovalCounter("dds-approvals", null,
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-issue-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-reprint-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-revoke-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"),
new ApprovalCounter("risk-change-approvals", null,
"SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'"));
}
@@ -0,0 +1,24 @@
package org.apache.fineract.portfolio.approvals.service;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.apache.fineract.useradministration.domain.AppUser;
/** Reads how many requests wait on each Approval Center tab, in one statement. */
public interface ApprovalCountsReadPlatformService {
/**
* Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the
* tenant of the current request.
*/
ApprovalCountsPlan planFor(AppUser user);
/** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */
ApprovalCountsData retrieve(ApprovalCountsPlan plan);
/**
* Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together
* cost the database one query. {@code fresh} skips the held answer.
*/
ApprovalCountsData retrieveHeld(AppUser user, boolean fresh);
}
@@ -0,0 +1,114 @@
package org.apache.fineract.portfolio.approvals.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
/**
* All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is
* one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked.
*/
@Service
@RequiredArgsConstructor
public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService {
/** Screens asking together read one answer instead of each running the statement. */
private static final long HOLD_MILLIS = 3_000L;
private record Held(long at, ApprovalCountsData data) {}
private final JdbcTemplate jdbcTemplate;
private final Map<String, Held> held = new ConcurrentHashMap<>();
@Override
public ApprovalCountsPlan planFor(final AppUser user) {
final List<ApprovalCounter> visible = new ArrayList<>();
for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) {
if (isVisible(user, counter)) {
visible.add(counter);
}
}
final String hierarchy = user.getOffice().getHierarchy();
final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER");
final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%")
.addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker);
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
// Callers counted the same way share one query: only a limited checker's numbers depend on who they are.
final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|'
+ (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(","));
return new ApprovalCountsPlan(tenant, visible, params, group);
}
@Override
public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) {
final Map<String, Long> counts = new LinkedHashMap<>();
final List<ApprovalCounter> visible = plan.visible();
if (!visible.isEmpty()) {
final StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < visible.size(); i++) {
sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i);
}
final Map<String, Object> row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(),
plan.params());
for (int i = 0; i < visible.size(); i++) {
final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L;
if (n > 0) {
counts.put(visible.get(i).key(), n);
}
}
}
return ApprovalCountsData.of(counts);
}
@Override
public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) {
final ApprovalCountsPlan plan = planFor(user);
final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId();
final long now = System.currentTimeMillis();
final Held recent = this.held.get(holdKey);
if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) {
return recent.data();
}
final ApprovalCountsData data = retrieve(plan);
this.held.put(holdKey, new Held(now, data));
if (this.held.size() > 500) {
this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS);
}
return data;
}
/** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */
private boolean isVisible(final AppUser user, final ApprovalCounter counter) {
if (counter.permission() != null) {
final boolean allowed = counter.permission().startsWith("READ_")
? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ")
: user.hasAnyPermission(counter.permission());
if (!allowed) {
return false;
}
}
return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user);
}
private boolean isChecker(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return true;
}
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
}
}
@@ -0,0 +1,145 @@
package org.apache.fineract.portfolio.approvals.service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.ws.rs.sse.OutboundSseEvent;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.springframework.stereotype.Service;
/**
* Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its
* numbers change, so it neither polls nor reloads.
*
* One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share
* its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the
* server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean
* connection, which bounds what a browser accumulates on one.
*/
@Service
@RequiredArgsConstructor
public class ApprovalCountsStreamService {
/** How often the open screens are looked at: the longest a change waits before it is pushed. */
private static final long CHECK_MILLIS = 1_000L;
private static final long PING_MILLIS = 15_000L;
private static final long LIFETIME_MILLIS = 300_000L;
/** A screen listening, and the version of the numbers it was last sent. */
private static final class Listener {
private final ApprovalCountsPlan plan;
private final SseEventSink sink;
private final Sse sse;
private final long expiresAt;
private volatile String version;
private volatile long lastSent;
private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) {
this.plan = plan;
this.sink = sink;
this.sse = sse;
this.expiresAt = expiresAt;
}
}
private final ApprovalCountsReadPlatformService readService;
private final Set<Listener> listeners = ConcurrentHashMap.newKeySet();
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> {
final Thread thread = new Thread(runnable, "approval-counts-stream");
thread.setDaemon(true);
return thread;
});
@PostConstruct
void startTicker() {
this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS);
}
@PreDestroy
void stopTicker() {
this.ticker.shutdownNow();
this.listeners.forEach(this::close);
}
/** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */
public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) {
final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS);
try {
publish(listener, this.readService.retrieve(plan));
} catch (final RuntimeException e) {
sink.close();
throw e;
}
this.listeners.add(listener);
}
/** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */
private void tick() {
if (this.listeners.isEmpty()) {
return;
}
final long now = System.currentTimeMillis();
final Map<String, ApprovalCountsData> computed = new HashMap<>();
for (final Listener listener : this.listeners) {
try {
if (listener.sink.isClosed() || now >= listener.expiresAt) {
close(listener);
continue;
}
ApprovalCountsData data = computed.get(listener.plan.group());
if (data == null) {
ThreadLocalContextUtil.setTenant(listener.plan.tenant());
data = this.readService.retrieve(listener.plan);
computed.put(listener.plan.group(), data);
}
if (!data.version().equals(listener.version)) {
publish(listener, data);
} else if (now - listener.lastSent >= PING_MILLIS) {
listener.lastSent = now;
send(listener, listener.sse.newEventBuilder().comment("ping").build());
}
} catch (final RuntimeException e) {
close(listener);
} finally {
ThreadLocalContextUtil.clearTenant();
}
}
}
private void publish(final Listener listener, final ApprovalCountsData data) {
listener.version = data.version();
listener.lastSent = System.currentTimeMillis();
send(listener, listener.sse.newEvent("counts", data.toJson()));
}
private void send(final Listener listener, final OutboundSseEvent event) {
listener.sink.send(event).whenComplete((sent, failure) -> {
if (failure != null) {
close(listener);
}
});
}
private void close(final Listener listener) {
this.listeners.remove(listener);
try {
listener.sink.close();
} catch (final RuntimeException ignored) {
// The screen is already gone.
}
}
}
@@ -56,7 +56,6 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage; import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
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.slf4j.Logger; import org.slf4j.Logger;
@@ -99,7 +98,6 @@ public class CashierDepositRequestApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService; private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create(); private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ── // ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
@@ -157,11 +155,6 @@ public class CashierDepositRequestApiResource {
throw apiError(400, "Amount must be greater than zero"); throw apiError(400, "Amount must be greater than zero");
} }
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT); kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
// An FD is funded once, with exactly its principal. Told here at the counter rather than when the
// manager approves; the FD deposit command checks the same again at posting.
if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) {
fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount);
}
validateDenominationTotal(denominationData, amount); validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue); validateDdLimit(ddFaceValue);
@@ -69,8 +69,6 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
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;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -92,8 +90,6 @@ public class ClerkDisbursementRequestApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder; private final FineractRequestContextHolder fineractRequestContextHolder;
private final VoucherService voucherService; private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create(); private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ──────── // ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ────────
@@ -206,9 +202,6 @@ public class ClerkDisbursementRequestApiResource {
} }
} }
// Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account.
validateFixedDepositLegs(creditEntries, requestType);
// Validate balance when both DR and CR entries present (collection mode) // Validate balance when both DR and CR entries present (collection mode)
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0 if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) { && totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
@@ -498,10 +491,6 @@ public class ClerkDisbursementRequestApiResource {
rejectRecurringDepositLeg(entry, entryType); rejectRecurringDepositLeg(entry, entryType);
} }
} }
// Re-checked here, not only at submit: another request (or a counter deposit) may have funded the
// same FD while this one waited, and a refusal now still leaves the request PENDING.
final Set<Long> fundedFixedDeposits = validateFixedDepositLegs(creditEntries,
record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT");
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently // Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
// posting duplicate Fineract transactions for the same request. // posting duplicate Fineract transactions for the same request.
@@ -784,12 +773,6 @@ public class ClerkDisbursementRequestApiResource {
} }
} // end else (disbursement/collection mode) } // end else (disbursement/collection mode)
// A funded FD's maturity date and amount were projected off its principal with no money in it;
// the direct credit above does not recompute them, so do it now that the money has landed.
for (final Long fdId : fundedFixedDeposits) {
depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId);
}
} catch (Exception ex) { } catch (Exception ex) {
// Compensate: undo all posted Fineract transactions in reverse order (best-effort). // Compensate: undo all posted Fineract transactions in reverse order (best-effort).
// This restores account balances even when a later entry fails mid-processing. // This restores account balances even when a later entry fails mid-processing.
@@ -1240,70 +1223,6 @@ public class ClerkDisbursementRequestApiResource {
} }
} }
/**
* Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label.
* <ul>
* <li>An FD is never debited here: money leaves an FD only through its maturity or premature closure. The
* direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.</li>
* <li>An FD credit is its first and only funding, for exactly its principal, and an FD appears once.</li>
* <li>The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund
* them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is
* the bank's own money and has no customer to compare.</li>
* </ul>
*
* @return the ids of the FDs this request funds, for the maturity recalculation after posting
*/
private Set<Long> validateFixedDepositLegs(final JsonArray entries, final String requestType) {
final Set<Long> fdIds = new LinkedHashSet<>();
Long firstFdId = null;
Long fdClientId = null;
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) {
continue;
}
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId);
if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) {
throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only"
+ " through its maturity or premature closure.");
}
if (!fdIds.add(accountId)) {
throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once,"
+ " with its full principal in one entry.");
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount);
if (firstFdId == null) {
firstFdId = accountId;
fdClientId = clientId;
} else if (!Objects.equals(fdClientId, clientId)) {
throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer"
+ " only. Raise a separate request for each customer.");
}
}
if (fdIds.isEmpty()) {
return fdIds;
}
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type)
|| !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(),
"CURRENT".equals(type), amount);
}
return fdIds;
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */ /** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300; private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -57,7 +57,6 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage; import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
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.slf4j.Logger; import org.slf4j.Logger;
@@ -86,7 +85,6 @@ public class ClerkWithdrawalRequestApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService; private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create(); private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ───────── // ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
@@ -129,12 +127,6 @@ public class ClerkWithdrawalRequestApiResource {
} else if (savingsAccountId == null) { } else if (savingsAccountId == null) {
throw apiError(400, "Savings account id is required for a withdrawal request"); throw apiError(400, "Savings account id is required for a withdrawal request");
} }
// Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip.
// Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account.
if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) {
throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from."
+ " Money leaves a fixed deposit only through its maturity or premature closure.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero"); throw apiError(400, "Amount must be greater than zero");
} }
@@ -392,9 +392,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate"); final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount"); final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
// An FD is funded once, with exactly its principal (see FixedDepositFundingValidator).
new FixedDepositFundingValidator(this.jdbcTemplate).validateFirstFunding(savingsId, transactionAmount);
final Map<String, Object> changes = new LinkedHashMap<>(); final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validatePreviousDayClosed(); this.denominationBreakdownWriter.validatePreviousDayClosed();
@@ -425,20 +422,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
} }
@Transactional
@Override
public void recalculateFixedDepositMaturity(final Long savingsId) {
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@Transactional @Transactional
@Override @Override
public CommandProcessingResult updateDepositAmountForRDAccount(Long savingsId, JsonCommand command) { public CommandProcessingResult updateDepositAmountForRDAccount(Long savingsId, JsonCommand command) {
@@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler; import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.event.business.domain.deposit.FixedDepositAccountCreateBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.deposit.FixedDepositAccountCreateBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.deposit.RecurringDepositAccountCreateBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.deposit.RecurringDepositAccountCreateBusinessEvent;
@@ -183,8 +184,16 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy, final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy,
DepositAccountType.FIXED_DEPOSIT); DepositAccountType.FIXED_DEPOSIT);
// A client may hold any number of Fixed Deposits on the same product, even while an earlier one is if (account.getClient() != null && account.productId() != null) {
// still active, so there is deliberately no one-active-account-per-product check here. long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Fixed Deposit account with the same product.",
"productId", account.productId());
}
}
final MathContext mc = MathContext.DECIMAL64; final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false; final boolean isPreMatureClosure = false;
@@ -260,8 +269,16 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(command, final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(command,
submittedBy, DepositAccountType.RECURRING_DEPOSIT); submittedBy, DepositAccountType.RECURRING_DEPOSIT);
// A client may hold any number of Recurring Deposits on the same product, even while an earlier one if (account.getClient() != null && account.productId() != null) {
// is still active, so there is deliberately no one-active-account-per-product check here. long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Recurring Deposit account with the same product.",
"productId", account.productId());
}
}
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account); this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
@@ -1,238 +0,0 @@
/**
* 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.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The rules a fixed deposit is funded under, shared by every path money can reach an FD by: the FD deposit
* command itself (cash at the counter, approved cashier deposit requests) and the clerk's Bank Transfer,
* Fund Transfer and Disbursement requests, which credit the account directly.
*
* <p>
* An FD opens with no money in it and is funded exactly once, with exactly its principal. A second credit
* would silently change the principal the maturity was agreed on; anything more goes into a new FD (several
* FDs on the same product are allowed). Money never leaves an FD through these paths either - only through
* its maturity or premature closure.
*
* <p>
* When the money comes from the customer's own Savings or Current account, that account must be able to pay
* it: active, not debit-frozen, out of any lock-in, belonging to the FD's client (or held jointly with them),
* and holding enough available balance once holds, liens, clearing holds and its minimum balance are set
* aside. The KYC debit restriction on that account is checked by the callers through KycRestrictionGuard.
*/
@Component
@RequiredArgsConstructor
public class FixedDepositFundingValidator {
/** m_savings_account.deposit_type_enum (Fineract DepositAccountType). */
public static final int DEPOSIT_TYPE_SAVINGS = 100;
public static final int DEPOSIT_TYPE_FIXED = 200;
private static final int STATUS_ACTIVE = 300;
private static final int TXN_TYPE_DEPOSIT = 1;
/** SavingsAccountSubStatusEnum.BLOCK (no debits or credits) and BLOCK_DEBIT. */
private static final int SUB_STATUS_BLOCK = 400;
private static final int SUB_STATUS_BLOCK_DEBIT = 600;
private final JdbcTemplate jdbcTemplate;
/** Whether this m_savings_account id is a fixed deposit (a CURRENT account id must not be passed here). */
public boolean isFixedDeposit(final Long savingsAccountId) {
if (savingsAccountId == null) {
return false;
}
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
Integer.class, savingsAccountId, DEPOSIT_TYPE_FIXED);
return count != null && count > 0;
}
/**
* The FD's first and only funding: the account must be an active, not yet matured fixed deposit with no
* money in it, and the amount must be exactly its principal.
*
* @return the FD's client id, for the source-account checks
*/
public Long validateFirstFunding(final Long fdAccountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT s.account_no, s.status_enum, s.deposit_type_enum, s.client_id, s.account_balance_derived,
t.deposit_amount, t.maturity_date
FROM m_savings_account s
LEFT JOIN m_deposit_account_term_and_preclosure t ON t.savings_account_id = s.id
WHERE s.id = ?
""", fdAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.account.not.found", "Fixed deposit account " + fdAccountId + " was not found.");
}
final Map<String, Object> fd = rows.get(0);
final String accountNo = (String) fd.get("account_no");
if (intOf(fd.get("deposit_type_enum")) != DEPOSIT_TYPE_FIXED) {
throw fail("error.msg.fd.funding.not.a.fixed.deposit", "Account " + accountNo + " is not a fixed deposit.");
}
if (intOf(fd.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.not.active",
"Fixed deposit " + accountNo + " is not active. Only an active fixed deposit can be funded.");
}
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final LocalDate maturityDate = fd.get("maturity_date") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (maturityDate != null && !businessDate.isBefore(maturityDate)) {
throw fail("error.msg.fd.funding.matured",
"Fixed deposit " + accountNo + " has reached its maturity date (" + maturityDate + ") and can no longer be funded.");
}
final Integer deposits = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE savings_account_id = ? AND transaction_type_enum = ?"
+ " AND is_reversed = false",
Integer.class, fdAccountId, TXN_TYPE_DEPOSIT);
if ((deposits != null && deposits > 0) || amountOf(fd.get("account_balance_derived")).signum() > 0) {
throw fail("error.msg.fd.funding.already.funded", "Fixed deposit " + accountNo
+ " is already funded. A fixed deposit is funded only once - open a new fixed deposit for more money.");
}
final BigDecimal principal = fd.get("deposit_amount") == null ? null : amountOf(fd.get("deposit_amount"));
if (principal == null || principal.signum() <= 0) {
throw fail("error.msg.fd.funding.principal.missing", "Fixed deposit " + accountNo + " has no deposit amount (principal) set.");
}
if (amount == null || amount.compareTo(principal) != 0) {
throw fail("error.msg.fd.funding.amount.mismatch", "Amount " + (amount == null ? "0" : amount.toPlainString())
+ " for fixed deposit " + accountNo + " must be exactly its principal " + principal.toPlainString() + " - not less, not more.");
}
return fd.get("client_id") == null ? null : ((Number) fd.get("client_id")).longValue();
}
/**
* The Savings or Current account an FD is being funded from.
*
* @param fdAccountId
* the FD being funded
* @param fdClientId
* its client, from {@link #validateFirstFunding}
* @param sourceAccountId
* m_savings_account id, or m_current_account id when {@code sourceIsCurrent}
*/
public void validateFundingSource(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final BigDecimal amount) {
final List<Map<String, Object>> rows = sourceIsCurrent ? jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, account_balance_derived, on_hold_funds_derived,
total_savings_amount_on_hold, min_required_balance, enforce_min_required_balance,
allow_overdraft, overdraft_limit, lockedin_until_date_derived
FROM m_current_account WHERE id = ?
""", sourceAccountId) : jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, deposit_type_enum, account_balance_derived,
on_hold_funds_derived, total_savings_amount_on_hold, total_savings_amount_on_hold_clearing,
min_required_balance, enforce_min_required_balance, lockedin_until_date_derived
FROM m_savings_account WHERE id = ?
""", sourceAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.source.not.found", "Source account " + sourceAccountId + " was not found.");
}
final Map<String, Object> src = rows.get(0);
final String accountNo = (String) src.get("account_no");
if (!sourceIsCurrent && intOf(src.get("deposit_type_enum")) != DEPOSIT_TYPE_SAVINGS) {
throw fail("error.msg.fd.funding.source.type",
"Account " + accountNo + " cannot fund a fixed deposit - only a Savings or Current account can.");
}
if (intOf(src.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.source.not.active", "Source account " + accountNo + " is not active.");
}
final int subStatus = intOf(src.get("sub_status_enum"));
if (subStatus == SUB_STATUS_BLOCK || subStatus == SUB_STATUS_BLOCK_DEBIT) {
throw fail("error.msg.fd.funding.source.debit.frozen", "Source account " + accountNo + " is blocked for debits (debit freeze).");
}
final LocalDate lockedUntil = src.get("lockedin_until_date_derived") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (lockedUntil != null && DateUtils.getBusinessLocalDate().isBefore(lockedUntil)) {
throw fail("error.msg.fd.funding.source.locked.in", "Source account " + accountNo + " is locked-in until " + lockedUntil + ".");
}
final Long sourceClientId = src.get("client_id") == null ? null : ((Number) src.get("client_id")).longValue();
if (!sameClientOrJointHolder(fdAccountId, fdClientId, sourceAccountId, sourceIsCurrent, sourceClientId)) {
throw fail("error.msg.fd.funding.source.other.client", "Source account " + accountNo
+ " does not belong to the fixed deposit's customer. Fund an FD only from the customer's own account"
+ " or one they hold jointly.");
}
BigDecimal available = amountOf(src.get("account_balance_derived")).subtract(amountOf(src.get("on_hold_funds_derived")))
.subtract(amountOf(src.get("total_savings_amount_on_hold")));
if (!sourceIsCurrent) {
available = available.subtract(amountOf(src.get("total_savings_amount_on_hold_clearing")));
}
if (Boolean.TRUE.equals(src.get("enforce_min_required_balance"))) {
available = available.subtract(amountOf(src.get("min_required_balance")));
}
if (sourceIsCurrent && Boolean.TRUE.equals(src.get("allow_overdraft"))) {
available = available.add(amountOf(src.get("overdraft_limit")));
}
if (amount == null || available.compareTo(amount) < 0) {
throw fail("error.msg.fd.funding.source.insufficient", "Source account " + accountNo + " has an available balance of "
+ available.max(BigDecimal.ZERO).toPlainString() + " (after holds, lien and minimum balance), less than "
+ (amount == null ? "0" : amount.toPlainString()) + ".");
}
}
private boolean sameClientOrJointHolder(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final Long sourceClientId) {
if (fdClientId == null) {
return false;
}
if (Objects.equals(fdClientId, sourceClientId)) {
return true;
}
// The FD's customer is a joint holder on the source account ...
final Integer onSource = sourceIsCurrent
? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_current_account_joint_holder WHERE current_account_id = ? AND client_id = ?",
Integer.class, sourceAccountId, fdClientId)
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
sourceAccountId, fdClientId);
if (onSource != null && onSource > 0) {
return true;
}
// ... or the source account's customer is a joint holder on the FD.
if (sourceClientId == null) {
return false;
}
final Integer onFd = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
fdAccountId, sourceClientId);
return onFd != null && onFd > 0;
}
private static int intOf(final Object value) {
return value instanceof Number n ? n.intValue() : -1;
}
private static BigDecimal amountOf(final Object value) {
return value instanceof BigDecimal b ? b : value instanceof Number n ? new BigDecimal(n.toString()) : BigDecimal.ZERO;
}
private static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -39,13 +39,6 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult depositToFDAccount(Long savingsId, JsonCommand command); CommandProcessingResult depositToFDAccount(Long savingsId, JsonCommand command);
/**
* Recomputes a fixed deposit's maturity date and amount from its real transaction history. Used after an FD
* is funded by a direct book credit (clerk Bank Transfer / Fund Transfer / Disbursement), which does not go
* through {@link #depositToFDAccount}.
*/
void recalculateFixedDepositMaturity(Long savingsId);
CommandProcessingResult depositToRDAccount(Long savingsId, JsonCommand command); CommandProcessingResult depositToRDAccount(Long savingsId, JsonCommand command);
CommandProcessingResult withdrawal(Long savingsId, JsonCommand command, DepositAccountType depositAccountType); CommandProcessingResult withdrawal(Long savingsId, JsonCommand command, DepositAccountType depositAccountType);