diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareClosureRequestApiResource.java deleted file mode 100644 index fa067f669..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareClosureRequestApiResource.java +++ /dev/null @@ -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 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 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 baseParams, final String fromDate, final String toDate, - final int offset, final int limit) { - final StringBuilder where = new StringBuilder(baseWhere); - final List 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 pageParams = new ArrayList<>(params); - pageParams.add(limit); - pageParams.add(offset); - - final List> 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 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 loadClientComplianceSummary(final Long clientId) { - final Map summary = new java.util.LinkedHashMap<>(); - if (clientId == null) { - return summary; - } - final Map 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> checks = new ArrayList<>(); - - final Long shareAccountId = ((Number) record.get("share_account_id")).longValue(); - final Map 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 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 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 checkResult(final String label, final boolean pass, final String detail) { - final Map 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> 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()); - } -} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index ff8319ae8..75f97f2a4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -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 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) {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index c488adb53..14a327cc8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -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;