feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

New ClerkShareWithdrawalRequestApiResource backs a clerk-submits/manager-
approves flow for share redemption: on submit, validates and persists the
clerk-chosen settlement Payment Type (must be non-cash) plus a full audit
snapshot of the settlement row; on approve, executes the real "redeemshares"
command and, in the same transaction, credits the redemption value to the
share account's linked savings account using that stored payment type (with
a hardcoded-lookup fallback for pre-existing rows). Adds a /validate endpoint
that re-checks live account/GL state (not just the submit-time snapshot) so
a manager can verify before approving. Also exposes linked-savings-account
data on ShareAccount reads so the frontend can display/validate it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 16:56:50 +05:30
co-authored by Claude Sonnet 5
parent d7d22382a5
commit f1d68d17fe
15 changed files with 1104 additions and 12 deletions
@@ -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<String> 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)));
}
@@ -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<String, Object> 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<Object> params = new ArrayList<>(List.of(currentUser.getId()));
if (status != null && !status.isBlank()) {
where.append(" AND status = ?");
params.add(status);
}
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
}
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
final int offset, final int limit) {
final StringBuilder where = new StringBuilder(baseWhere);
final List<Object> params = new ArrayList<>(baseParams);
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
params.add(Date.valueOf(fromDate));
}
if (toDate != null && !toDate.isBlank()) {
where.append(" AND DATE(submitted_at) <= ?");
params.add(Date.valueOf(toDate));
}
final long total = Optional
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
params.toArray()))
.orElse(0L);
final List<Object> pageParams = new ArrayList<>(params);
pageParams.add(limit);
pageParams.add(offset);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> checkResult(final String label, final boolean pass, final String detail) {
final Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("label", label);
result.put("pass", pass);
result.put("detail", detail);
return result;
}
// ── 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<String, Object> 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<Map<String, Object>> 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());
}
}
@@ -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<ShareAccountDividendData> dividends) {
this.dividends = dividends;
}
@@ -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;
}
@@ -147,6 +147,12 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccount", orphanRemoval = true, fetch = FetchType.EAGER)
private Set<ShareAccountCharge> 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<Long> {
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;
}
@@ -67,6 +67,14 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
@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<ShareAccountChargePaidBy> shareAccountChargesPaid = new HashSet<>();
@@ -109,6 +117,22 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
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;
@@ -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<String> 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<String> 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<ApiParameterError> 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<ApiParameterError> 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<ApiParameterError> 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");
}
}
}
@@ -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() {
@@ -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() {
@@ -491,4 +491,9 @@
<include file="parts/0435_add_aml_officer_and_principal_officer_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0436_fix_missing_client_risk_rule_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0437_update_aml_columns_for_risk_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0438_share_account_withdrawal_governance.xml" relativeToChangelogFile="true"/>
<include file="parts/0439_create_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0440_seed_share_redemption_payment_type.xml" relativeToChangelogFile="true"/>
<include file="parts/0441_add_settlement_payment_type_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0442_add_settlement_row_snapshot_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!--
Share withdrawal governance: a share account may optionally be linked to a loan (held
as margin/security) so a redeem-shares request can be checked against that loan's
outstanding balance; a redeem transaction records the Board Resolution Number and Date
of Meeting that authorised it.
-->
<changeSet author="fineract" id="0438-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_share_account" columnName="linked_loan_id"/></not>
</preConditions>
<addColumn tableName="m_share_account">
<column name="linked_loan_id" type="BIGINT"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0438-2">
<preConditions onFail="MARK_RAN">
<not><foreignKeyConstraintExists foreignKeyName="FK_m_share_account_linked_loan_id"/></not>
</preConditions>
<addForeignKeyConstraint baseTableName="m_share_account" baseColumnNames="linked_loan_id"
referencedTableName="m_loan" referencedColumnNames="id"
constraintName="FK_m_share_account_linked_loan_id" />
</changeSet>
<changeSet author="fineract" id="0438-3">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_share_account_transactions" columnName="resolution_number"/></not>
</preConditions>
<addColumn tableName="m_share_account_transactions">
<column name="resolution_number" type="VARCHAR(100)"/>
<column name="meeting_date" type="DATE"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Maker-checker for Share Account withdrawal (redeem shares) requests, submitted only from
the Clerk Dashboard — mirrors m_clerk_withdrawal_request (cash withdrawal), but records the
Board Resolution Number / Date of Meeting a share withdrawal is governed by, and links to a
Share Account instead of a Savings/Current account.
-->
<changeSet id="0439_create_clerk_share_withdrawal_request" author="CBS">
<createTable tableName="m_clerk_share_withdrawal_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="share_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="account_no" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="client_id" type="BIGINT"/>
<column name="client_name" type="VARCHAR(255)"/>
<column name="requested_shares" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="requested_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="resolution_number" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="meeting_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="description" type="TEXT"/>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="clerk_id" type="BIGINT"/>
<column name="clerk_name" type="VARCHAR(255)"/>
<column name="clerk_username" type="VARCHAR(100)"/>
<column name="manager_id" type="BIGINT"/>
<column name="manager_name" type="VARCHAR(255)"/>
<column name="manager_username" type="VARCHAR(100)"/>
<column name="rejection_reason" type="TEXT"/>
<column name="share_transaction_id" type="VARCHAR(100)"/>
<column name="activity_log" type="TEXT"/>
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="actioned_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_clerk_id">
<column name="clerk_id"/>
</createIndex>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_submitted_at">
<column name="submitted_at"/>
</createIndex>
<createIndex tableName="m_clerk_share_withdrawal_request" indexName="idx_cswr_share_account_id">
<column name="share_account_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Real-world UCB practice: a share redemption's value is never handed over as cash from the
Share department — it is a book-transfer credited to the member's linked savings account
(Dr Share Capital, Cr Savings Control). This is a deliberate, manual SECOND entry the clerk
makes on the Transaction Entry screen (Deposit Slip on the linked savings account) after the
Share Withdrawal Request is approved and shares are redeemed — the redemption itself never
triggers this automatically. This Payment Type is the fund-source channel that drives correct
GL routing for that manual deposit; an accountant maps it to the Share Capital GL account via
the existing "Map Payment Types to Specific Funds" screen (we intentionally do NOT hardcode a
GL account id here — see 0345's fix for why a tenant-specific GL id baked into a migration
breaks on any tenant seeded differently). runOnChange lets this file's wording/flag tweaks
safely re-validate on any environment where it already ran — the "not already seeded"
precondition below is already false there, so it's skipped rather than re-inserted. -->
<changeSet author="fineract" id="0440-1-seed-share-redemption-payment-type" runOnChange="true">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">select count(*) from m_payment_type where code_name = 'SHARE_REDEMPTION_TRANSFER'</sqlCheck>
</preConditions>
<insert tableName="m_payment_type">
<column name="value" value="Share Redemption Transfer"/>
<column name="description" value="Manual book-transfer settlement of a share redemption to the member's linked savings account"/>
<column name="is_cash_payment" valueBoolean="false"/>
<column name="order_position" valueNumeric="0"/>
<column name="code_name" value="SHARE_REDEMPTION_TRANSFER"/>
<column name="is_system_defined" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The clerk picks which Payment Type the settlement deposit (credit to the linked savings
account) should use — this is captured at submit time, persisted here, validated (must not
be a cash payment type — settlement is always a book-transfer), and then actually used at
approval time instead of a hardcoded payment type lookup. Each column below is its own
changeset with its own existence guard — an earlier, now-superseded revision of this file
already created settlement_amount/settlement_savings_transaction_id on some environments
(including local dev), so a single combined addColumn would fail wherever those two already
exist but settlement_payment_type_id does not. -->
<changeSet author="fineract" id="0441-1-add-settlement-payment-type-id">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_payment_type_id"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_payment_type_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_clerk_share_wd_settlement_pt" references="m_payment_type(id)"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0441-2-add-settlement-amount">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_amount"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_amount" type="DECIMAL(19,6)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0441-3-add-settlement-savings-transaction-id">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_savings_transaction_id"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_savings_transaction_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Captures a full snapshot (as submitted JSON) of the CR/settlement row the clerk filled in on
the "Enter Bank Transfer" screen — account no., ECS a/c no., amount, cheque no./date, token
no., description, bank name, routing code — for audit purposes, alongside the columns that
already exist for the DR (Share) row's own fields (resolution_number, meeting_date, etc). -->
<changeSet author="fineract" id="0442-1-add-settlement-row-snapshot">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_row_snapshot"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_row_snapshot" type="TEXT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>