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 new file mode 100644 index 000000000..fa067f669 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareClosureRequestApiResource.java @@ -0,0 +1,576 @@ +/** + * 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 1a971b8cb..ff8319ae8 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,6 +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); + final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); res.addProperty("status", "APPROVED"); @@ -646,9 +652,63 @@ public class ClerkShareWithdrawalRequestApiResource { } else { res.addProperty("settlementWarning", settlementNote); } + if (autoClosureRequestId != null) { + res.addProperty("autoClosureRequestId", autoClosureRequestId); + } 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. + */ + private Long autoCreateClosureRequestIfSharesZero(final Long shareAccountId, final String requestedDateStr, + final Long withdrawalRequestId, final Map withdrawalRecord) { + 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; + } + + 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())); + } + private record Settlement(BigDecimal amount, Long savingsTransactionId) {} /** diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 5b299e3c4..c84de8987 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -498,4 +498,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0446_create_clerk_share_closure_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0446_create_clerk_share_closure_request.xml new file mode 100644 index 000000000..283b0e301 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0446_create_clerk_share_closure_request.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +