diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java index 346accadd..c77bf5afa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java @@ -102,12 +102,23 @@ public interface ShareAccountApiConstants { String shareEntityType = "share"; + /** Required on a redeem-shares (withdrawal) request — Board Resolution Number and Date of + * Meeting authorising the withdrawal (cooperative-sector share capital governance). */ + String redeemresolutionnumber_paramname = "resolutionNumber"; + + String redeemmeetingdate_paramname = "meetingDate"; + + /** Loan this share account is held as margin/security against — optional, set via the + * account update command. When present, a redeem request is checked against the loan's + * outstanding balance. */ + String linkedloanid_paramname = "linkedLoanId"; + Set supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname, id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname, resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname, requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname, minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname, allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname, - numberofshares_paramname, purchasedprice_paramname))); + numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname))); } 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 new file mode 100644 index 000000000..2f138064e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -0,0 +1,615 @@ +/** + * 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.math.BigDecimal; +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 withdrawal (redeem shares) request workflow for a Share Account, submitted only + * from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the + * governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which + * server-side executes the real Fineract "redeemshares" command against the share account, or + * rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same + * maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share + * redemption is a book-entry, not a cash handover). + */ +@Path("/v1/clerk/share-withdrawal-requests") +@Component +@Tag(name = "Clerk Share Withdrawal Requests", description = "Maker-checker share redemption workflow for clerk and bank manager.") +@RequiredArgsConstructor +public class ClerkShareWithdrawalRequestApiResource { + + private final JdbcTemplate jdbcTemplate; + private final FromJsonHelper fromJsonHelper; + private final PlatformSecurityContext context; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + // Raw java.sql.Date/Timestamp values coming straight off jdbcTemplate row Maps (requested_date, + // meeting_date, submitted_at, actioned_at) get serialized by Gson's *default* locale-dependent + // format (e.g. "Aug 5, 2026 12:00:00 AM") unless a format is set here — that broke the frontend's + // simple split('T')[0]/split(' ')[0] date parsing, which expects an ISO-shaped string. + 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", + "requested_shares", "requested_date", "resolution_number", "meeting_date", "description", "status", "clerk_id", "clerk_name", + "clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id", + "activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id", + "(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name", + "settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" }; + + private static final String SHARE_REDEMPTION_PAYMENT_TYPE_CODE = "SHARE_REDEMPTION_TRANSFER"; + + // ── POST /v1/clerk/share-withdrawal-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 Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root); + final String requestedDate = fromJsonHelper.extractStringNamed("requestedDate", root); + final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root); + final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root); + final String description = fromJsonHelper.extractStringNamed("description", root); + final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root); + // Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no., + // amount, cheque no./date, token no., description, bank name, routing code, payment type) — + // stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads + // it, since the CR row's account number is only ever used client-side to double-check against + // the share account's own authoritative linked-savings-account link. + final JsonElement snapshotElement = root.isJsonObject() ? root.getAsJsonObject().get("settlementRowSnapshot") : null; + final String settlementRowSnapshot = snapshotElement != null && !snapshotElement.isJsonNull() ? snapshotElement.toString() + : null; + + if (shareAccountId == null) { + throw apiError(400, "Share account id is required"); + } + if (accountNo == null || accountNo.isBlank()) { + throw apiError(400, "Account number is required"); + } + if (requestedShares == null || requestedShares <= 0) { + throw apiError(400, "Number of shares to withdraw must be greater than zero"); + } + if (requestedDate == null || requestedDate.isBlank()) { + throw apiError(400, "Requested date is required"); + } + if (resolutionNumber == null || resolutionNumber.isBlank()) { + throw apiError(400, "Board Resolution Number is required for a share withdrawal"); + } + if (meetingDate == null || meetingDate.isBlank()) { + throw apiError(400, "Date of Meeting is required for a share withdrawal"); + } + if (settlementPaymentTypeId == null) { + throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited"); + } + validateSettlementPaymentType(settlementPaymentTypeId); + + final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); + + final Long newId = jdbcTemplate.queryForObject(""" + INSERT INTO m_clerk_share_withdrawal_request + (share_account_id, account_no, client_id, client_name, requested_shares, requested_date, + resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username, + activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + RETURNING id + """, + Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate), + resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(), + currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot); + + return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); + } + + /** + * Settlement is always a book-transfer from Share Sundry to the linked savings account — never a + * cash payout at the teller counter — so cash-flagged payment types are rejected here regardless + * of what the clerk picked in the UI. + */ + private void validateSettlementPaymentType(final Long paymentTypeId) { + final Map paymentType = jdbcTemplate + .queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", paymentTypeId).stream().findFirst() + .orElse(null); + if (paymentType == null) { + throw apiError(400, "Settlement Payment Type " + paymentTypeId + " does not exist"); + } + final boolean isCash = Boolean.TRUE.equals(paymentType.get("is_cash_payment")); + if (isCash) { + throw apiError(400, "Settlement Payment Type '" + paymentType.get("value") + + "' is a cash payment type — share redemption settlement to a linked savings account must be a non-cash book-transfer"); + } + } + + // ── GET /v1/clerk/share-withdrawal-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-withdrawal-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_withdrawal_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_withdrawal_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-withdrawal-requests/{id} ── Full detail ───────────── + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + public String getById(@PathParam("id") final Long id) { + return gson.toJson(loadRecord(id)); + } + + // ── POST /v1/clerk/share-withdrawal-requests/{id}/validate ── Manager runs live checks ── + + /** + * Re-runs every check against the CURRENT live state (not the snapshot taken at submit time), so + * the checker can catch things that changed since submission — a share/savings account that got + * closed, a payment type that got deactivated, or (the exact bug class this session ran into) a + * savings product's GL Fund Source mapping for this payment type going missing, which would + * silently post the settlement leg to the default Cash at Vault account instead of Share Sundry. + */ + @POST + @Path("/{id}/validate") + @Produces(MediaType.APPLICATION_JSON) + public String validate(@PathParam("id") final Long id) { + final Map record = loadRecord(id); + final List> checks = new ArrayList<>(); + + final String status = (String) record.get("status"); + checks.add(checkResult("Request status is PENDING", "PENDING".equals(status), String.valueOf(status))); + + final String resolutionNumber = (String) record.get("resolution_number"); + checks.add(checkResult("Board Resolution Number provided", resolutionNumber != null && !resolutionNumber.isBlank(), + resolutionNumber == null || resolutionNumber.isBlank() ? "Missing" : resolutionNumber)); + + final Object meetingDate = record.get("meeting_date"); + checks.add(checkResult("Date of Meeting provided", meetingDate != null, + meetingDate == null ? "Missing" : meetingDate.toString().substring(0, Math.min(10, meetingDate.toString().length())))); + + final Long shareAccountId = ((Number) record.get("share_account_id")).longValue(); + final Long requestedShares = ((Number) record.get("requested_shares")).longValue(); + + final Map shareAccountRow = jdbcTemplate.queryForList(""" + SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, savings_account_id AS savingsAccountId + 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() + : 0L; + checks.add(checkResult("Shares requested within current shares held", shareAccountRow != null && requestedShares <= heldShares, + requestedShares + " of " + heldShares + " shares 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, "Status code: " + shareAccountStatus)); + + final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null + ? ((Number) shareAccountRow.get("savingsAccountId")).longValue() + : null; + checks.add(checkResult("Linked savings account confirmed", savingsAccountId != null, + savingsAccountId != null ? "Savings account id " + savingsAccountId : "No linked savings account configured")); + + Integer savingsStatus = null; + Long savingsProductId = null; + if (savingsAccountId != null) { + final Map savingsRow = jdbcTemplate + .queryForList("SELECT status_enum AS statusEnum, product_id AS productId FROM m_savings_account WHERE id = ?", + savingsAccountId) + .stream().findFirst().orElse(null); + savingsStatus = savingsRow != null && savingsRow.get("statusEnum") != null ? ((Number) savingsRow.get("statusEnum")).intValue() + : null; + savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue() + : null; + } + checks.add(checkResult("Linked savings account is Active", savingsStatus != null && savingsStatus == 300, + savingsStatus != null ? "Status code: " + savingsStatus : "Not found")); + + final Object settlementPaymentTypeIdObj = record.get("settlement_payment_type_id"); + final Long settlementPaymentTypeId = settlementPaymentTypeIdObj instanceof Number + ? ((Number) settlementPaymentTypeIdObj).longValue() + : null; + final Object settlementPaymentTypeName = record.get("settlement_payment_type_name"); + checks.add(checkResult("Settlement Payment Type selected", settlementPaymentTypeId != null, + settlementPaymentTypeName != null ? settlementPaymentTypeName.toString() : "Missing")); + + boolean paymentTypeNonCash = false; + if (settlementPaymentTypeId != null) { + final Map pt = jdbcTemplate + .queryForList("SELECT is_cash_payment FROM m_payment_type WHERE id = ?", settlementPaymentTypeId).stream() + .findFirst().orElse(null); + paymentTypeNonCash = pt != null && !Boolean.TRUE.equals(pt.get("is_cash_payment")); + } + checks.add(checkResult("Settlement Payment Type is non-cash", paymentTypeNonCash, + paymentTypeNonCash ? "Confirmed non-cash" : "Cash payment type or not found — settlement would be rejected")); + + boolean glMappingOk = false; + String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check"; + if (savingsProductId != null && settlementPaymentTypeId != null) { + final Map mapping = jdbcTemplate.queryForList(""" + SELECT gl.name AS glName FROM acc_product_mapping apm + JOIN acc_gl_account gl ON gl.id = apm.gl_account_id + WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1 + """, savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null); + glMappingOk = mapping != null; + glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName") + : "No GL Fund Source override configured for this Payment Type — settlement would silently post to the default Cash at Vault account instead of Share Sundry"; + } + checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail)); + + 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; + } + + // ── POST /v1/clerk/share-withdrawal-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 withdrawal request you submitted yourself. Ask another manager to review it."); + } + + final Long shareAccountId = ((Number) record.get("share_account_id")).longValue(); + final Long requestedShares = ((Number) record.get("requested_shares")).longValue(); + final Object requestedDateObj = record.get("requested_date"); + final Object meetingDateObj = record.get("meeting_date"); + final String resolutionNumber = (String) record.get("resolution_number"); + + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("requestedDate", formatForFineract(requestedDateObj.toString())); + payload.addProperty("requestedShares", requestedShares); + payload.addProperty("resolutionNumber", resolutionNumber); + payload.addProperty("meetingDate", formatForFineract(meetingDateObj.toString())); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "redeemshares") + .withJson(payload.toString()).build(); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + // redeemShares() puts the newly-created share transaction's id under "requestedShares" in + // the changes map — NOT in getResourceId(), which for this command is the share ACCOUNT id + // instead. Reading getResourceId() here would silently look up the wrong row downstream. + final Object shareTxnIdRaw = result.getChanges() != null ? result.getChanges().get("requestedShares") : null; + final Long shareTransactionId = shareTxnIdRaw instanceof Number ? ((Number) shareTxnIdRaw).longValue() : null; + final String shareTxnId = shareTransactionId != null ? shareTransactionId.toString() : null; + + // Settlement: within this SAME approval transaction, immediately credit the redemption value + // to the member's linked savings account. Two Fineract journal entries get posted — native + // redeem's Dr Share Equity / Cr Shares Reference, then this deposit's Dr Shares Reference / + // Cr Savings Control — but the Shares Reference leg nets to zero instantly, so the combined + // financial result is exactly Dr Share Equity / Cr Savings Control, same as one direct entry. + final Object storedSettlementPaymentTypeId = record.get("settlement_payment_type_id"); + final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number + ? ((Number) storedSettlementPaymentTypeId).longValue() + : null; + final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), + resolutionNumber, settlementPaymentTypeId); + + final String settlementNote = settlement != null + ? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")" + : "No linked savings account on this share account — settlement must be completed manually."; + final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), + settlementNote); + + final int approvedRows = jdbcTemplate.update(""" + UPDATE m_clerk_share_withdrawal_request SET + status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?, + share_transaction_id = ?, activity_log = ?, actioned_at = ?, + settlement_amount = ?, settlement_savings_transaction_id = ? + WHERE id = ? AND status = 'PENDING' + """, currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog, + Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null, + settlement != null ? settlement.savingsTransactionId() : null, id); + if (approvedRows == 0) { + throw apiError(409, "This share withdrawal request was already actioned by another manager."); + } + + final JsonObject res = new JsonObject(); + res.addProperty("resourceId", id); + res.addProperty("status", "APPROVED"); + if (shareTxnId != null) { + res.addProperty("shareTransactionId", shareTxnId); + } + if (settlement != null) { + res.addProperty("settlementAmount", settlement.amount()); + res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId()); + } else { + res.addProperty("settlementWarning", settlementNote); + } + return gson.toJson(res); + } + + private record Settlement(BigDecimal amount, Long savingsTransactionId) {} + + /** + * Credits the redemption value to the share account's linked savings account, if one exists. + * Reads the amount straight off the just-created redeem transaction row rather than re-deriving + * the unit price ourselves — the transaction was already priced by Fineract's own + * {@code ShareProduct#deriveMarketPrice(requestedDate)} (the requested date, not necessarily + * today) and already nets out any redemption charge. Uses Fineract's own native savings deposit + * command so the transaction is fully audited and posts its own GL entry the normal way — the + * debit side lands on whichever GL account is mapped to the settlement payment type the clerk + * chose at submit time (validated to be non-cash and persisted on the request row) — matching the + * Share Product's own "Shares Reference" mapping (Share Sundry) — so the two entries net to zero + * on that bridge account. Falls back to the legacy hardcoded + * {@link #SHARE_REDEMPTION_PAYMENT_TYPE_CODE} lookup only for requests submitted before this + * column existed. + */ + private Settlement creditLinkedSavingsAccount(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr, + final String resolutionNumber, final Long settlementPaymentTypeId) { + if (shareTransactionId == null) { + return null; + } + final Map row = jdbcTemplate.queryForList(""" + SELECT sa.savings_account_id AS savingsAccountId, sat.amount AS settlementAmount + FROM m_share_account_transactions sat JOIN m_share_account sa ON sa.id = sat.account_id + WHERE sat.id = ? + """, shareTransactionId).stream().findFirst().orElse(null); + if (row == null) { + return null; + } + final Long savingsAccountId = row.get("savingsAccountId") == null ? null : ((Number) row.get("savingsAccountId")).longValue(); + if (savingsAccountId == null) { + return null; + } + + final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount"); + + final Long paymentTypeId = settlementPaymentTypeId != null ? settlementPaymentTypeId + : jdbcTemplate.queryForList("SELECT id FROM m_payment_type WHERE code_name = ?", Long.class, + SHARE_REDEMPTION_PAYMENT_TYPE_CODE).stream().findFirst().orElse(null); + + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", formatForFineract(requestedDateStr)); + payload.addProperty("transactionAmount", settlementAmount); + if (paymentTypeId != null) { + payload.addProperty("paymentTypeId", paymentTypeId); + } + payload.addProperty("note", "Share redemption settlement — Resolution No. " + resolutionNumber); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId) + .withJson(payload.toString()).build(); + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + return new Settlement(settlementAmount, result.getResourceId()); + } + + // ── POST /v1/clerk/share-withdrawal-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_withdrawal_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 withdrawal 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 withdrawal requests."); + } + } + + private Map loadRecord(final Long id) { + final List> rows = jdbcTemplate + .queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id); + if (rows.isEmpty()) { + throw new NotFoundException("Share withdrawal 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/shareaccounts/data/ShareAccountData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java index 4114f219b..ffcccd5c2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountData.java @@ -67,6 +67,10 @@ public class ShareAccountData implements Serializable, AccountData { private Long savingsAccountId; + private Long linkedLoanId; + + private String linkedLoanAccountNo; + private BigDecimal currentMarketPrice; private Integer lockinPeriod; @@ -301,6 +305,11 @@ public class ShareAccountData implements Serializable, AccountData { this.currentMarketPrice = currentMarketPrice; } + public void setLinkedLoan(final Long linkedLoanId, final String linkedLoanAccountNo) { + this.linkedLoanId = linkedLoanId; + this.linkedLoanAccountNo = linkedLoanAccountNo; + } + public void setDividends(Collection dividends) { this.dividends = dividends; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java index 180a5fbd9..0ce4065fa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java @@ -51,4 +51,10 @@ public class ShareAccountTransactionData implements Serializable { private final BigDecimal amountPaid; + /** Board Resolution Number / Date of Meeting that authorised this transaction — populated + * only for a redeem (withdrawal) transaction; null otherwise. */ + private final String resolutionNumber; + + private final LocalDate meetingDate; + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java index cfeab7ae1..fec686e0e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java @@ -147,6 +147,12 @@ public class ShareAccount extends AbstractPersistableCustom { @OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccount", orphanRemoval = true, fetch = FetchType.EAGER) private Set charges; + /** Optional loan this share account is held as margin/security against. A raw id (not a JPA + * relation) since shareaccounts and loanaccount are otherwise unrelated aggregate roots in + * this codebase — kept loosely coupled, resolved via a lookup where needed. */ + @Column(name = "linked_loan_id") + private Long linkedLoanId; + @Transient protected boolean accountNumberRequiresAutoGeneration = false; @@ -599,6 +605,19 @@ public class ShareAccount extends AbstractPersistableCustom { return this.activatedDate; } + public Long getLinkedLoanId() { + return this.linkedLoanId; + } + + public boolean setLinkedLoanId(final Long linkedLoanId) { + boolean returnValue = false; + if (this.linkedLoanId == null || !this.linkedLoanId.equals(linkedLoanId)) { + this.linkedLoanId = linkedLoanId; + returnValue = true; + } + return returnValue; + } + public Integer status() { return this.status; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java index af21e9cab..45b314bcc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java @@ -67,6 +67,14 @@ public class ShareAccountTransaction extends AbstractPersistableCustom { @Column(name = "is_active", nullable = false) private boolean active = true; + /** Board Resolution Number and Date of Meeting authorising this transaction — required for a + * share withdrawal (redeem), a governance requirement for cooperative-sector share capital. */ + @Column(name = "resolution_number") + private String resolutionNumber; + + @Column(name = "meeting_date") + private LocalDate meetingDate; + @OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER) private Set shareAccountChargesPaid = new HashSet<>(); @@ -109,6 +117,22 @@ public class ShareAccountTransaction extends AbstractPersistableCustom { return new ShareAccountTransaction(transactionDate, totalShares, shareValue, status, type, amount, null, amountPaid); } + public static ShareAccountTransaction createRedeemTransaction(final LocalDate transactionDate, final Long totalShares, + final BigDecimal shareValue, final String resolutionNumber, final LocalDate meetingDate) { + ShareAccountTransaction transaction = createRedeemTransaction(transactionDate, totalShares, shareValue); + transaction.resolutionNumber = resolutionNumber; + transaction.meetingDate = meetingDate; + return transaction; + } + + public String getResolutionNumber() { + return this.resolutionNumber; + } + + public LocalDate getMeetingDate() { + return this.meetingDate; + } + public static ShareAccountTransaction createChargeTransaction(final LocalDate transactionDate, final ShareAccountCharge charge) { final Long totalShares = null; final BigDecimal unitPrice = null; 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 ba9fc54ce..c488adb53 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 @@ -72,6 +72,7 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @Service @@ -93,6 +94,8 @@ public class ShareAccountDataSerializer { private final SavingsAccountReadPlatformService savingsAccountReadPlatformService; + private final JdbcTemplate jdbcTemplate; + private static final Set approvalParameters = new HashSet<>( Arrays.asList(ShareAccountApiConstants.locale_paramname, ShareAccountApiConstants.dateformat_paramname, ShareAccountApiConstants.approveddate_paramname, ShareAccountApiConstants.note_paramname)); @@ -106,13 +109,14 @@ public class ShareAccountDataSerializer { private static final Set addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname, ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname, - ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname)); + ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname, + ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname)); @Autowired public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper, final ChargeRepositoryWrapper chargeRepository, final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, final ClientRepositoryWrapper clientRepositoryWrapper, final ShareProductRepositoryWrapper shareProductRepository, - final SavingsAccountReadPlatformService savingsAccountReadPlatformService) { + final SavingsAccountReadPlatformService savingsAccountReadPlatformService, final JdbcTemplate jdbcTemplate) { this.platformSecurityContext = platformSecurityContext; this.fromApiJsonHelper = fromApiJsonHelper; this.chargeRepository = chargeRepository; @@ -120,6 +124,7 @@ public class ShareAccountDataSerializer { this.clientRepositoryWrapper = clientRepositoryWrapper; this.shareProductRepository = shareProductRepository; this.savingsAccountReadPlatformService = savingsAccountReadPlatformService; + this.jdbcTemplate = jdbcTemplate; } public ShareAccount validateAndCreate(JsonCommand jsonCommand) { @@ -313,6 +318,13 @@ public class ShareAccountDataSerializer { } } + if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.linkedloanid_paramname, element)) { + final Long linkedLoanId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.linkedloanid_paramname, element); + if (account.setLinkedLoanId(linkedLoanId)) { + actualChanges.put(ShareAccountApiConstants.linkedloanid_paramname, linkedLoanId); + } + } + if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.savingsaccountid_paramname, element)) { Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element); baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull() @@ -885,6 +897,13 @@ public class ShareAccountDataSerializer { final Long sharesRequested = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element); baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(sharesRequested).notNull() .longGreaterThanZero(); + // Governance requirement: a share withdrawal must be authorised by a Board Resolution. + final String resolutionNumber = this.fromApiJsonHelper + .extractStringNamed(ShareAccountApiConstants.redeemresolutionnumber_paramname, element); + baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemresolutionnumber_paramname).value(resolutionNumber).notBlank(); + final LocalDate meetingDate = this.fromApiJsonHelper.extractLocalDateNamed(ShareAccountApiConstants.redeemmeetingdate_paramname, + element); + baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemmeetingdate_paramname).value(meetingDate).notNull(); boolean isTransactionBeforeExistingTransactions = false; isTransactionBeforeExistingTransactions = isTransactionBeforeExistingTransactions(requestedDate, isTransactionBeforeExistingTransactions, account); @@ -896,7 +915,8 @@ public class ShareAccountDataSerializer { throw new PlatformApiDataValidationException(dataValidationErrors); } BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(requestedDate); - ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(requestedDate, sharesRequested, unitPrice); + ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(requestedDate, sharesRequested, unitPrice, + resolutionNumber, meetingDate); validateRedeemRequest(account, transaction, baseDataValidator, dataValidationErrors); account.addAdditionalPurchasedShares(transaction); actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction); @@ -917,6 +937,18 @@ public class ShareAccountDataSerializer { throw new PlatformApiDataValidationException(dataValidationErrors); } + final Long remainingShares = account.getTotalApprovedShares() - redeemTransaction.getTotalShares(); + + validateAgainstLinkedLoan(account, redeemTransaction, remainingShares, baseDataValidator, dataValidationErrors); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + validateMinimumSharesAndTenure(account, redeemTransaction, remainingShares, baseDataValidator, dataValidationErrors); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + LocalDate redeemDate = redeemTransaction.getPurchasedDate(); final Integer lockinPeriod = account.getLockinPeriodFrequency(); final PeriodFrequencyType periodType = account.getLockinPeriodFrequencyType(); @@ -967,6 +999,63 @@ public class ShareAccountDataSerializer { } } + /** If this share account is held as margin/security against a loan, the shares remaining + * after this redemption (at current unit price) must still cover that loan's outstanding + * balance. */ + private void validateAgainstLinkedLoan(final ShareAccount account, final ShareAccountTransaction redeemTransaction, + final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List dataValidationErrors) { + final Long linkedLoanId = account.getLinkedLoanId(); + if (linkedLoanId == null) { + return; + } + BigDecimal outstandingLoanBalance; + try { + outstandingLoanBalance = this.jdbcTemplate.queryForObject( + "SELECT COALESCE(total_outstanding_derived, principal_disbursed_derived, 0) FROM m_loan WHERE id = ? AND loan_status_id = 300", + BigDecimal.class, linkedLoanId); + } catch (org.springframework.dao.EmptyResultDataAccessException e) { + // Linked loan is no longer active (closed/written-off) — nothing left to protect. + outstandingLoanBalance = null; + } + if (outstandingLoanBalance == null) { + return; + } + final BigDecimal remainingShareValue = redeemTransaction.getPurchasePrice().multiply(BigDecimal.valueOf(remainingShares)); + if (remainingShareValue.compareTo(outstandingLoanBalance) < 0) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname) + .value(redeemTransaction.getTotalShares()) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.shares.linked.to.active.loan.margin.shortfall"); + } + } + + /** If this redemption would bring the holding below the Share Product's configured minimum + * client shares, the account must have been active for at least the product's configured + * minimum active period before the reduction is allowed. */ + private void validateMinimumSharesAndTenure(final ShareAccount account, final ShareAccountTransaction redeemTransaction, + final Long remainingShares, final DataValidatorBuilder baseDataValidator, final List dataValidationErrors) { + final Long minimumClientShares = account.getShareProduct().getMinimumClientShares(); + if (minimumClientShares == null || remainingShares >= minimumClientShares) { + return; + } + final Integer minimumActivePeriod = account.getMinimumActivePeriodFrequency(); + final PeriodFrequencyType minimumActivePeriodType = account.getMinimumActivePeriodFrequencyType(); + final LocalDate activatedDate = account.getActivatedDate(); + if (minimumActivePeriod == null || minimumActivePeriodType == null || activatedDate == null) { + // Product enforces a minimum balance but defines no minimum tenure — reducing below + // minimum is simply not allowed at all in that case. + baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname) + .value(redeemTransaction.getTotalShares()) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.below.minimum.shares.required.by.product"); + return; + } + final LocalDate eligibleFromDate = deriveLockinPeriodDuration(minimumActivePeriod, minimumActivePeriodType, activatedDate); + if (DateUtils.isAfter(eligibleFromDate, redeemTransaction.getPurchasedDate())) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.requesteddate_paramname) + .value(redeemTransaction.getPurchasedDate()) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.redeemed.below.minimum.shares.before.minimum.tenure"); + } + } + private LocalDate deriveLockinPeriodDuration(final Integer lockinPeriod, final PeriodFrequencyType periodType, LocalDate purchaseDate) { LocalDate lockinDate = purchaseDate; if (periodType != null) { @@ -1044,6 +1133,11 @@ public class ShareAccountDataSerializer { throw new PlatformApiDataValidationException(dataValidationErrors); } + validateNoActiveLoanOrGuaranteeBeforeClosure(account, baseDataValidator, dataValidationErrors); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + AppUser approvedUser = this.platformSecurityContext.authenticatedUser(); final BigDecimal unitPrice = account.getShareProduct().deriveMarketPrice(DateUtils.getBusinessLocalDate()); ShareAccountTransaction transaction = ShareAccountTransaction.createRedeemTransaction(closedDate, account.getTotalApprovedShares(), @@ -1054,4 +1148,42 @@ public class ShareAccountDataSerializer { actualChanges.put(ShareAccountApiConstants.requestedshares_paramname, transaction); return actualChanges; } + + /** A share account cannot be closed while the member has an active loan (share capital is + * often required as continuing membership/security while a liability is outstanding) or is + * standing as an active guarantor on someone else's active loan. */ + private void validateNoActiveLoanOrGuaranteeBeforeClosure(final ShareAccount account, final DataValidatorBuilder baseDataValidator, + final List dataValidationErrors) { + final Long clientId = account.getClientId(); + Integer activeLoanCount; + try { + activeLoanCount = this.jdbcTemplate.queryForObject( + "SELECT COUNT(id) FROM m_loan WHERE client_id = ? AND loan_status_id = 300", Integer.class, clientId); + } catch (org.springframework.dao.EmptyResultDataAccessException e) { + activeLoanCount = 0; + } + if (activeLoanCount != null && activeLoanCount > 0) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname) + .failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.member.has.active.loan"); + return; + } + + Integer activeGuaranteeCount; + try { + activeGuaranteeCount = this.jdbcTemplate.queryForObject(""" + SELECT COUNT(g.id) + 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 + """, Integer.class, clientId); + } catch (org.springframework.dao.EmptyResultDataAccessException e) { + activeGuaranteeCount = 0; + } + if (activeGuaranteeCount != null && activeGuaranteeCount > 0) { + baseDataValidator.reset().parameter(ShareAccountApiConstants.closeddate_paramname) + .failWithCodeNoParameterAddedToErrorCode("share.account.cannot.be.closed.member.has.active.guarantee"); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java index e9c961ebf..00f5769d5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java @@ -50,7 +50,8 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe StringBuilder buff = new StringBuilder() .append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ") .append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ") - .append("saps.amount_paid as amountPaid").append(" from m_share_account_transactions saps "); + .append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate") + .append(" from m_share_account_transactions saps "); schema = buff.toString(); } @@ -68,9 +69,12 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "amount"); final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "chargeamount"); final BigDecimal amountPaid = JdbcSupport.getBigDecimalDefaultToZeroIfNull(rs, "amountPaid"); + final String resolutionNumber = rs.getString("resolutionNumber"); + final java.sql.Date meetingDateSql = rs.getDate("meetingDate"); + final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null; return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum, - amount, chargeAmount, amountPaid); + amount, chargeAmount, amountPaid, resolutionNumber, meetingDate); } public String schema() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java index f50ead8fb..038249f89 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java @@ -267,6 +267,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat this.purchasedShares = purchasedShares; StringBuilder buff = new StringBuilder().append( "sa.id as id, sa.external_id as externalId, sa.resolution_number as resolutionNumber, sa.status_enum as statusEnum, ") + .append("sa.linked_loan_id as linkedLoanId, ml.account_no as linkedLoanAccountNo, ") .append("sa.savings_account_id, msa.account_no as savingsAccNo, ") .append("c.id as clientId, c.display_name as clientName, ") .append("sa.account_no as accountNo, sa.total_approved_shares as approvedShares, sa.total_pending_shares as pendingShares, ") @@ -294,7 +295,8 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat .append("left join m_appuser abu on abu.id = sa.approved_userid ") .append("left join m_appuser avbu on rbu.id = sa.activated_userid ") .append("left join m_appuser cbu on cbu.id = sa.closed_userid ") - .append("left join m_savings_account msa on sa.savings_account_id = msa.id "); + .append("left join m_savings_account msa on sa.savings_account_id = msa.id ") + .append("left join m_loan ml on ml.id = sa.linked_loan_id "); this.schema = buff.toString(); } @@ -304,6 +306,8 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final String accountNo = rs.getString("accountNo"); final String externalId = rs.getString("externalId"); final String resolutionNumber = rs.getString("resolutionNumber"); + final Long linkedLoanId = JdbcSupport.getLong(rs, "linkedLoanId"); + final String linkedLoanAccountNo = rs.getString("linkedLoanAccountNo"); final Long savingsAccountId = JdbcSupport.getLong(rs, "savings_account_id"); final String savingsAccountNumber = rs.getString("savingsAccNo"); final Long clientId = JdbcSupport.getLong(rs, "clientId"); @@ -375,10 +379,12 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final String shortProductName = null; final ShareAccountSummaryData summary = new ShareAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, totalApprovedShares, totalPendingShares, timeline); - return new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId, savingsAccountNumber, clientId, - clientName, productId, productName, status, timeline, currency, summary, charges, purchasedShares, - lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType, + final ShareAccountData shareAccountData = new ShareAccountData(id, accountNo, externalId, resolutionNumber, savingsAccountId, + savingsAccountNumber, clientId, clientName, productId, productName, status, timeline, currency, summary, charges, + purchasedShares, lockinPeriodFrequency, lockinPeriodFrequencyType, minimumActivePeriod, minimumActivePeriodType, allowdividendsforinactiveclients); + shareAccountData.setLinkedLoan(linkedLoanId, linkedLoanAccountNo); + return shareAccountData; } @@ -461,7 +467,7 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat StringBuilder buff = new StringBuilder().append( "saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ") .append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ") - .append("saps.amount_paid as amountPaid "); + .append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate "); schema = buff.toString(); } @@ -480,8 +486,11 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat final BigDecimal amount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "amount"); final BigDecimal chargeAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "chargeamount"); final BigDecimal amountPaid = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "amountPaid"); + final String resolutionNumber = rs.getString("resolutionNumber"); + final java.sql.Date meetingDateSql = rs.getDate("meetingDate"); + final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null; return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum, - amount, chargeAmount, amountPaid); + amount, chargeAmount, amountPaid, resolutionNumber, meetingDate); } public String schema() { 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 0f5949b19..f701e4ed9 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 @@ -491,4 +491,9 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0438_share_account_withdrawal_governance.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0438_share_account_withdrawal_governance.xml new file mode 100644 index 000000000..8c93fb79b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0438_share_account_withdrawal_governance.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0439_create_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0439_create_clerk_share_withdrawal_request.xml new file mode 100644 index 000000000..4788f6665 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0439_create_clerk_share_withdrawal_request.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0440_seed_share_redemption_payment_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0440_seed_share_redemption_payment_type.xml new file mode 100644 index 000000000..546c77d3a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0440_seed_share_redemption_payment_type.xml @@ -0,0 +1,32 @@ + + + + + + + select count(*) from m_payment_type where code_name = 'SHARE_REDEMPTION_TRANSFER' + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0441_add_settlement_payment_type_to_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0441_add_settlement_payment_type_to_clerk_share_withdrawal_request.xml new file mode 100644 index 000000000..a0b356d01 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0441_add_settlement_payment_type_to_clerk_share_withdrawal_request.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0442_add_settlement_row_snapshot_to_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0442_add_settlement_row_snapshot_to_clerk_share_withdrawal_request.xml new file mode 100644 index 000000000..bfbdcbaef --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0442_add_settlement_row_snapshot_to_clerk_share_withdrawal_request.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + +