Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cfa7ef3d1 |
@@ -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',
|
||||||
|
|||||||
+67
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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) {}
|
||||||
+27
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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) {}
|
||||||
+122
@@ -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'"));
|
||||||
|
}
|
||||||
+24
@@ -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);
|
||||||
|
}
|
||||||
+114
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-7
@@ -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);
|
||||||
|
|
||||||
|
|||||||
-81
@@ -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;
|
||||||
|
|
||||||
|
|||||||
-8
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
-17
@@ -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) {
|
||||||
|
|||||||
+21
-4
@@ -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);
|
||||||
|
|
||||||
|
|||||||
-238
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-7
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user