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
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:
+12
-1
@@ -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)));
|
||||
|
||||
}
|
||||
|
||||
+615
@@ -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());
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
|
||||
+24
@@ -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;
|
||||
|
||||
+135
-3
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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() {
|
||||
|
||||
+15
-6
@@ -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>
|
||||
|
||||
+60
@@ -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>
|
||||
+90
@@ -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>
|
||||
+32
@@ -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>
|
||||
+53
@@ -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>
|
||||
+23
@@ -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>
|
||||
Reference in New Issue
Block a user