feat(shares): close share account directly on withdrawal approval
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (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 - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract 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

Replaces the auto-submit-closure-request behavior with an immediate
account close, in the same @Transactional approval action, when a
redemption brings a share account's holding to zero — no separate
"Share Closure Request" maker-checker step is needed since the
redemption amount itself already went through this same review.
Removes ClerkShareClosureRequestApiResource entirely (the migration
for m_clerk_share_closure_request is left in place as a harmless,
now-unused table, to avoid a Liquibase checksum churn).

Also fixes a genuine Postgres-incompatibility bug this uncovered in
validateNoActiveLoanOrGuaranteeBeforeClosure(): `is_active = 1`
doesn't type-check against a boolean column on Postgres ("operator
does not exist: boolean = integer"), which was silently aborting the
whole approval transaction (harmlessly, since it's @Transactional —
verified no partial state was ever left behind). Fixed to `is_active
= true`, added the missing `type_enum = 1` (customer-type guarantors
only, matching the existing Guarantor Check elsewhere in this
workflow), and widened both this and the active-loan check to the
same loan_status_id set (300, 303, 304, 700) used there too.
This commit is contained in:
2026-08-06 19:29:44 +05:30
parent 1426be9531
commit c568eb0abf
3 changed files with 27 additions and 628 deletions
@@ -1,576 +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.clerkwithdrawal.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Maker-checker closure request workflow for a Share Account, submitted only from the Clerk
* Dashboard, on demand once all shares have already been withdrawn. A clerk (maker) submits a
* closure request naming the closed date and an optional note; a Bank Manager (checker) approves
* it, which server-side executes the real Fineract "close" command against the share account, or
* rejects it. Mirrors {@link ClerkShareWithdrawalRequestApiResource} — same maker-checker shape,
* adapted for closure (no shares/settlement leg — closing an already-zero-share account is a
* status change, not a further redemption).
*/
@Path("/v1/clerk/share-closure-requests")
@Component
@Tag(name = "Clerk Share Closure Requests", description = "Maker-checker share account closure workflow for clerk and bank manager.")
@RequiredArgsConstructor
public class ClerkShareClosureRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final String[] LIST_COLUMNS = { "id", "share_account_id", "account_no", "client_id", "client_name", "closed_date",
"note", "status", "clerk_id", "clerk_name", "clerk_username", "manager_id", "manager_name", "manager_username",
"rejection_reason", "activity_log", "submitted_at", "actioned_at" };
// ── POST /v1/clerk/share-closure-requests ── Clerk submits new request ──────
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submit(final String body) {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
final Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final String closedDate = fromJsonHelper.extractStringNamed("closedDate", root);
final String note = fromJsonHelper.extractStringNamed("note", root);
if (shareAccountId == null) {
throw apiError(400, "Share account id is required");
}
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "Account number is required");
}
if (closedDate == null || closedDate.isBlank()) {
throw apiError(400, "Closed date is required");
}
if (LocalDate.parse(closedDate.length() >= 10 ? closedDate.substring(0, 10) : closedDate, DateTimeFormatter.ISO_LOCAL_DATE)
.isAfter(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()))) {
throw apiError(400, "Closed date cannot be in the future");
}
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum
FROM m_share_account WHERE id = ?
""", shareAccountId).stream().findFirst().orElse(null);
if (shareAccountRow == null) {
throw apiError(400, "Share account " + shareAccountId + " does not exist");
}
final long heldShares = shareAccountRow.get("totalApprovedShares") != null
? ((Number) shareAccountRow.get("totalApprovedShares")).longValue()
: 0L;
if (heldShares != 0) {
throw apiError(400,
"This share account still holds " + heldShares + " share(s) — all shares must be withdrawn before closure can be requested");
}
final int shareAccountStatus = shareAccountRow.get("statusEnum") != null
? ((Number) shareAccountRow.get("statusEnum")).intValue()
: -1;
if (shareAccountStatus != 300) {
throw apiError(400, "Only an Active share account can be closed (current status code: " + shareAccountStatus + ")");
}
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_clerk_share_closure_request
(share_account_id, account_no, client_id, client_name, closed_date, note, status, clerk_id, clerk_name,
clerk_username, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class, shareAccountId, accountNo, clientId, clientName, Date.valueOf(closedDate.substring(0, 10)), note, "PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()));
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
// ── GET /v1/clerk/share-closure-requests ── Manager sees all requests ───────
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
}
// ── GET /v1/clerk/share-closure-requests/my ── Clerk's own requests ─────────
@GET
@Path("/my")
@Produces(MediaType.APPLICATION_JSON)
public String myRequests(@QueryParam("status") final String status, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate) {
final AppUser currentUser = context.authenticatedUser();
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
if (status != null && !status.isBlank()) {
where.append(" AND status = ?");
params.add(status);
}
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
}
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
final int offset, final int limit) {
final StringBuilder where = new StringBuilder(baseWhere);
final List<Object> params = new ArrayList<>(baseParams);
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
params.add(Date.valueOf(fromDate));
}
if (toDate != null && !toDate.isBlank()) {
where.append(" AND DATE(submitted_at) <= ?");
params.add(Date.valueOf(toDate));
}
final long total = Optional
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_closure_request " + where, Long.class,
params.toArray()))
.orElse(0L);
final List<Object> pageParams = new ArrayList<>(params);
pageParams.add(limit);
pageParams.add(offset);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_closure_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
final JsonObject result = new JsonObject();
result.addProperty("totalFilteredRecords", total);
result.add("pageItems", gson.toJsonTree(rows));
return gson.toJson(result);
}
// ── GET /v1/clerk/share-closure-requests/{id} ── Full detail ────────────────
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String getById(@PathParam("id") final Long id) {
final Map<?, ?> record = loadRecord(id);
final Map<String, Object> enriched = new java.util.LinkedHashMap<>();
for (final Map.Entry<?, ?> entry : record.entrySet()) {
enriched.put(String.valueOf(entry.getKey()), entry.getValue());
}
final Object clientIdObj = record.get("client_id");
final Long clientId = clientIdObj instanceof Number ? ((Number) clientIdObj).longValue() : null;
enriched.putAll(loadClientComplianceSummary(clientId));
return gson.toJson(enriched);
}
private Map<String, Object> loadClientComplianceSummary(final Long clientId) {
final Map<String, Object> summary = new java.util.LinkedHashMap<>();
if (clientId == null) {
return summary;
}
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
WHERE client_id = ? ORDER BY id DESC LIMIT 1
""", clientId).stream().findFirst().orElse(null);
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null ? ((Number) kycRow.get("riskCategory")).intValue()
: null;
final Long openAlerts = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
summary.put("client_kyc_status_label", kycStatus != null ? kycStatusLabel(kycStatus) : "No KYC record");
summary.put("client_kyc_verified", kycStatus != null && kycStatus == 300);
summary.put("client_risk_category_label", riskCategory != null ? riskCategoryLabel(riskCategory) : "Not classified");
summary.put("client_risk_high", riskCategory != null && riskCategory == 3);
summary.put("client_open_aml_alerts", openAlerts != null ? openAlerts : 0L);
return summary;
}
// ── POST /v1/clerk/share-closure-requests/{id}/validate ── Manager runs live checks ──
/**
* Re-runs every check against the CURRENT live state before a manager approves closure —
* shares could have been re-purchased since submission, a loan could have been linked, or the
* client's compliance standing could have changed.
*/
@POST
@Path("/{id}/validate")
@Produces(MediaType.APPLICATION_JSON)
public String validate(@PathParam("id") final Long id) {
final AppUser currentUser = context.authenticatedUser();
final Map<?, ?> record = loadRecord(id);
final List<Map<String, Object>> checks = new ArrayList<>();
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum,
client_id AS clientId, linked_loan_id AS linkedLoanId
FROM m_share_account WHERE id = ?
""", shareAccountId).stream().findFirst().orElse(null);
final long heldShares = shareAccountRow != null && shareAccountRow.get("totalApprovedShares") != null
? ((Number) shareAccountRow.get("totalApprovedShares")).longValue()
: -1L;
checks.add(checkResult("No shares currently held", heldShares == 0, heldShares + " share(s) currently held"));
final int shareAccountStatus = shareAccountRow != null && shareAccountRow.get("statusEnum") != null
? ((Number) shareAccountRow.get("statusEnum")).intValue()
: -1;
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
final Long clientId = shareAccountRow != null && shareAccountRow.get("clientId") != null
? ((Number) shareAccountRow.get("clientId")).longValue()
: null;
// Loan / Tenure / Guarantor checks — mirrors the same checks in
// ClerkShareWithdrawalRequestApiResource.validate() and Fineract's own native
// ShareAccountDataSerializer.validateAndClose(), which already blocks closure server-side if
// the client has an active loan or is an active guarantor.
final Long linkedLoanId = shareAccountRow != null && shareAccountRow.get("linkedLoanId") != null
? ((Number) shareAccountRow.get("linkedLoanId")).longValue()
: null;
Map<String, Object> linkedLoanRow = null;
if (linkedLoanId != null) {
linkedLoanRow = jdbcTemplate.queryForList("""
SELECT account_no AS accountNo, loan_status_id AS statusId, expected_maturedon_date AS expectedMaturedOnDate
FROM m_loan WHERE id = ?
""", linkedLoanId).stream().findFirst().orElse(null);
}
final Integer linkedLoanStatus = linkedLoanRow != null && linkedLoanRow.get("statusId") != null
? ((Number) linkedLoanRow.get("statusId")).intValue()
: null;
final boolean linkedLoanOutstanding = linkedLoanStatus != null && isLoanStatusOutstanding(linkedLoanStatus);
final String loanCheckDetail;
if (linkedLoanId == null) {
loanCheckDetail = "No loan linked to this share account";
} else if (linkedLoanRow == null) {
loanCheckDetail = "Loan #" + linkedLoanId + " — record not found";
} else {
loanCheckDetail = "Loan " + linkedLoanRow.get("accountNo") + " — " + loanStatusLabel(linkedLoanStatus);
}
checks.add(checkResult("Loan Check — no active loan secured by these shares", linkedLoanId == null || !linkedLoanOutstanding,
loanCheckDetail));
if (clientId != null) {
final Long activeLoanCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_loan WHERE client_id = ? AND loan_status_id = 300", Long.class, clientId);
checks.add(checkResult("Client has no other active loan", activeLoanCount == null || activeLoanCount == 0,
activeLoanCount != null && activeLoanCount > 0 ? activeLoanCount + " active loan(s)" : "None"));
final Long guarantorActiveLoans = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_guarantor g JOIN m_loan l ON l.id = g.loan_id
WHERE g.entity_id = ? AND g.type_enum = 1 AND g.is_active = true
AND l.loan_status_id IN (300, 303, 304, 700)
""", Long.class, clientId);
checks.add(checkResult("Guarantor Check — not guaranteeing any active loan",
guarantorActiveLoans == null || guarantorActiveLoans == 0,
guarantorActiveLoans != null && guarantorActiveLoans > 0 ? "Guarantor on " + guarantorActiveLoans + " active loan(s)"
: "None"));
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc
WHERE client_id = ? ORDER BY id DESC LIMIT 1
""", clientId).stream().findFirst().orElse(null);
final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null;
checks.add(checkResult("Client KYC is Verified", kycStatus != null && kycStatus == 300,
kycStatus == null ? "No KYC record found" : kycStatusLabel(kycStatus)));
final Long openAlerts = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId);
checks.add(checkResult("No open AML alerts for this client", openAlerts == null || openAlerts == 0,
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
final Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("label", label);
result.put("pass", pass);
result.put("detail", detail);
return result;
}
private boolean isLoanStatusOutstanding(final int loanStatusId) {
return loanStatusId == 300 || loanStatusId == 303 || loanStatusId == 304 || loanStatusId == 700;
}
private String loanStatusLabel(final int code) {
return switch (code) {
case 100 -> "Submitted and Pending Approval";
case 200 -> "Approved";
case 300 -> "Active";
case 303 -> "Transfer in Progress";
case 304 -> "Transfer on Hold";
case 400 -> "Withdrawn by Client";
case 500 -> "Rejected";
case 600 -> "Closed (Obligations Met)";
case 601 -> "Closed (Written Off)";
case 602 -> "Closed (Rescheduled)";
case 700 -> "Overpaid";
default -> "Unknown (" + code + ")";
};
}
private String shareStatusLabel(final int code) {
return switch (code) {
case 100 -> "Submitted and Pending Approval";
case 200 -> "Approved";
case 300 -> "Active";
case 500 -> "Rejected";
case 600 -> "Closed";
default -> "Unknown (" + code + ")";
};
}
private String kycStatusLabel(final int code) {
return switch (code) {
case 100 -> "Pending";
case 200 -> "In Progress";
case 300 -> "Verified";
case 400 -> "Rejected";
case 500 -> "Expired";
default -> "Unknown (" + code + ")";
};
}
private String riskCategoryLabel(final int code) {
return switch (code) {
case 1 -> "Low";
case 2 -> "Medium";
case 3 -> "High";
default -> "Unknown (" + code + ")";
};
}
// ── POST /v1/clerk/share-closure-requests/{id}/approve ── Manager approves ──
@POST
@Path("/{id}/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
}
final Long makerId = ((Number) record.get("clerk_id")).longValue();
if (makerId.equals(currentUser.getId())) {
throw apiError(403,
"Maker-checker: you cannot approve a share closure request you submitted yourself. Ask another manager to review it.");
}
final Long shareAccountId = ((Number) record.get("share_account_id")).longValue();
final Object closedDateObj = record.get("closed_date");
final String note = (String) record.get("note");
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("closedDate", formatForFineract(closedDateObj.toString()));
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close")
.withJson(payload.toString()).build();
commandsSourceWritePlatformService.logCommandSource(commandRequest);
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(),
"Share account closed");
final int approvedRows = jdbcTemplate.update("""
UPDATE m_clerk_share_closure_request SET
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
activity_log = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), updatedLog,
Timestamp.from(Instant.now()), id);
if (approvedRows == 0) {
throw apiError(409, "This share closure request was already actioned by another manager.");
}
return gson.toJson(Map.of("resourceId", id, "status", "APPROVED"));
}
// ── POST /v1/clerk/share-closure-requests/{id}/reject ── Manager rejects ────
@POST
@Path("/{id}/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String reject(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
}
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (reason == null || reason.isBlank()) {
throw apiError(400, "Rejection reason is required");
}
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason);
final int rejectedRows = jdbcTemplate.update("""
UPDATE m_clerk_share_closure_request SET
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
rejection_reason = ?, activity_log = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), reason, updatedLog,
Timestamp.from(Instant.now()), id);
if (rejectedRows == 0) {
throw apiError(409, "This share closure request was already actioned by another manager.");
}
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
}
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject share closure requests.");
}
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_closure_request WHERE id = ?", id);
if (rows.isEmpty()) {
throw new NotFoundException("Share closure request not found: " + id);
}
return rows.get(0);
}
private String formatForFineract(final String isoOrTimestamp) {
try {
final String isoDatePart = isoOrTimestamp.length() >= 10 ? isoOrTimestamp.substring(0, 10) : isoOrTimestamp;
final LocalDate ld = LocalDate.parse(isoDatePart, DateTimeFormatter.ISO_LOCAL_DATE);
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
} catch (Exception e) {
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
}
}
private String buildActivityLog(final String existingJson, final String action, final String username, final String note) {
JsonArray log = new JsonArray();
if (existingJson != null && !existingJson.isBlank()) {
try {
log = fromJsonHelper.parse(existingJson).getAsJsonArray();
} catch (Exception e) {
log = new JsonArray();
}
}
final JsonObject entry = new JsonObject();
entry.addProperty("timestamp", Instant.now().toString());
entry.addProperty("action", action);
entry.addProperty("user", username);
if (note != null && !note.isBlank()) {
entry.addProperty("note", note);
}
log.add(entry);
return gson.toJson(log);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", message);
errors.add(err);
body.add("errors", errors);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -634,11 +634,12 @@ public class ClerkShareWithdrawalRequestApiResource {
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
}
// If this redemption brought the account's holding to zero, auto-submit a PENDING closure
// request rather than making the clerk separately open "New Share Closure Request" — a
// manager (any manager other than the maker below) still has to Validate and Approve it,
// so the closure maker-checker governance is preserved end to end.
final Long autoClosureRequestId = autoCreateClosureRequestIfSharesZero(shareAccountId, requestedDateObj.toString(), id, record);
// If this redemption brought the account's holding to zero, close the share account right
// here in the SAME approval action — no separate "Share Closure Request" maker-checker step.
// Safe to do inline (unlike a raw manual "Close" from the account screen) because the
// redemption amount itself already went through this same Maker-Checker review; there are no
// shares left for the native close command to force-redeem without oversight.
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
@@ -652,61 +653,33 @@ public class ClerkShareWithdrawalRequestApiResource {
} else {
res.addProperty("settlementWarning", settlementNote);
}
if (autoClosureRequestId != null) {
res.addProperty("autoClosureRequestId", autoClosureRequestId);
if (accountClosed) {
res.addProperty("accountClosed", true);
}
return gson.toJson(res);
}
/**
* Auto-submits a PENDING row into m_clerk_share_closure_request when this redemption has just
* brought the share account's holding to zero — the clerk doesn't have to separately open "New
* Share Closure Request", but a manager still has to review, Validate and Approve it there
* before the account actually closes, exactly as if the clerk had submitted it by hand. Reuses
* the withdrawal request's own maker identity so the manager who reviews the closure (who must
* differ from that clerk, same as any other maker-checker check) sees a consistent audit trail.
* Closes the share account when this redemption has just brought its holding to zero — the
* account closes immediately on approval, in the same step as the withdrawal itself, rather than
* needing a separate closure request to be submitted and approved afterwards.
*/
private Long autoCreateClosureRequestIfSharesZero(final Long shareAccountId, final String requestedDateStr,
final Long withdrawalRequestId, final Map<?, ?> withdrawalRecord) {
private boolean closeShareAccountIfSharesZero(final Long shareAccountId, final String requestedDateStr) {
final Long remainingShares = jdbcTemplate.queryForObject("SELECT total_approved_shares FROM m_share_account WHERE id = ?",
Long.class, shareAccountId);
if (remainingShares == null || remainingShares != 0) {
return null;
return false;
}
final Long existingPending = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_clerk_share_closure_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class,
shareAccountId);
if (existingPending != null && existingPending > 0) {
return null;
}
final Map<String, Object> shareAccountRow = jdbcTemplate
.queryForList("SELECT account_no AS accountNo, client_id AS clientId FROM m_share_account WHERE id = ?", shareAccountId)
.stream().findFirst().orElse(null);
if (shareAccountRow == null) {
return null;
}
final String accountNo = (String) shareAccountRow.get("accountNo");
final Long clientId = shareAccountRow.get("clientId") != null ? ((Number) shareAccountRow.get("clientId")).longValue() : null;
final String clientName = clientId != null
? jdbcTemplate.queryForList("SELECT display_name FROM m_client WHERE id = ?", clientId).stream().findFirst()
.map(m -> (String) m.get("display_name")).orElse(null)
: null;
final String note = "Auto-submitted — all shares withdrawn on approval of Share Withdrawal Request #" + withdrawalRequestId;
final String activityLog = buildActivityLog(null, "SUBMITTED", "system", note);
final String closedDate = requestedDateStr.length() >= 10 ? requestedDateStr.substring(0, 10) : requestedDateStr;
return jdbcTemplate.queryForObject("""
INSERT INTO m_clerk_share_closure_request
(share_account_id, account_no, client_id, client_name, closed_date, note, status, clerk_id, clerk_name,
clerk_username, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""", Long.class, shareAccountId, accountNo, clientId, clientName, Date.valueOf(closedDate), note, "PENDING",
withdrawalRecord.get("clerk_id"), withdrawalRecord.get("clerk_name"), withdrawalRecord.get("clerk_username"), activityLog,
Timestamp.from(Instant.now()));
final JsonObject closePayload = new JsonObject();
closePayload.addProperty("locale", "en");
closePayload.addProperty("dateFormat", "dd MMMM yyyy");
closePayload.addProperty("closedDate", formatForFineract(requestedDateStr));
closePayload.addProperty("note", "Closed automatically — all shares withdrawn on approval of this Share Withdrawal Request.");
final CommandWrapper closeCommand = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close")
.withJson(closePayload.toString()).build();
commandsSourceWritePlatformService.logCommandSource(closeCommand);
return true;
}
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
@@ -1158,7 +1158,8 @@ public class ShareAccountDataSerializer {
Integer activeLoanCount;
try {
activeLoanCount = this.jdbcTemplate.queryForObject(
"SELECT COUNT(id) FROM m_loan WHERE client_id = ? AND loan_status_id = 300", Integer.class, clientId);
"SELECT COUNT(id) FROM m_loan WHERE client_id = ? AND loan_status_id IN (300, 303, 304, 700)", Integer.class,
clientId);
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
activeLoanCount = 0;
}
@@ -1175,8 +1176,9 @@ public class ShareAccountDataSerializer {
FROM m_guarantor g
JOIN m_loan l ON l.id = g.loan_id
WHERE g.entity_id = ?
AND g.is_active = 1
AND l.loan_status_id = 300
AND g.type_enum = 1
AND g.is_active = true
AND l.loan_status_id IN (300, 303, 304, 700)
""", Integer.class, clientId);
} catch (org.springframework.dao.EmptyResultDataAccessException e) {
activeGuaranteeCount = 0;