feat(shares): centralized Additional Shares approval queue, remove unused Share Savings Debit
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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 Docker Builds / build (true, docker-compose.yml, mariadb) (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 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 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 - 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 Cargo & Unit- & Integration tests - PostgreSQL / 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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 Docker Builds / build (true, docker-compose.yml, mariadb) (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 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 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 - 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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
- Add ShareAdditionRequestsApiResource (/v1/shareadditionrequests): lists every pending additional-share tranche across all accounts, with a per-tranche detail view carrying the route-specific proof of funding a list row can't show (which voucher(s) fund a cash tranche, or which savings account a savings tranche already debited). Approval/rejection still go through Fineract's own approveadditionalshares/rejectadditionalshares commands unchanged -- this is a read side only, so the existing settle()/unwind() hooks fire from exactly one code path. - Remove ShareSavingsDebitRequestApiResource and its m_share_savings_debit_request table (migration 0520): confirmed zero rows on both tenants since it was built, and superseded by Additional Shares' own savings-funding route, which values a tranche on its own rather than by summing the account's entire holding history. - Update comments in ShareAccountWritePlatformServiceJpaRepositoryImpl, ShareAccountDataSerializer, AdditionalShareTrancheFundingService and AdditionalSharesFundingApiResource that referenced the removed class/endpoint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
-629
@@ -1,629 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.cashtransfer.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
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.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.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.exception.PlatformDataIntegrityException;
|
||||
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.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Settles a share application out of the member's own savings account.
|
||||
*
|
||||
* The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks
|
||||
* in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks
|
||||
* with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Savings Control (share value + entry fee)
|
||||
* Cr Share Sundry (share value + entry fee)
|
||||
*
|
||||
* Dr Share Sundry (share value + entry fee)
|
||||
* Cr Share Capital (share value)
|
||||
* Cr Entry Fee Income (charge, when one is attached)
|
||||
* </pre>
|
||||
*
|
||||
* Both steps are posted together on approval, so Sundry nets to zero for the settlement and holds
|
||||
* nothing afterwards. It is still routed through rather than around: every other way money reaches
|
||||
* share capital — the cashier's advance collection, and both additional-share routes — passes through
|
||||
* Sundry, and that balance is what the share ledger reconciles against.
|
||||
*
|
||||
* Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval
|
||||
* touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair
|
||||
* of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves.
|
||||
*
|
||||
* The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and
|
||||
* running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own
|
||||
* Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and
|
||||
* replaced with the share-capital pair above.
|
||||
*/
|
||||
@Path("/v1/sharesavingsdebit")
|
||||
@Component
|
||||
@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.")
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ShareSavingsDebitRequestApiResource {
|
||||
|
||||
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
|
||||
private static final int CREDIT = 1;
|
||||
private static final int DEBIT = 2;
|
||||
|
||||
// acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource.
|
||||
/** Share Sundry — the holding account the member's money passes through on its way to capital. */
|
||||
private static final int FA_SHARES_SUSPENSE = 2;
|
||||
private static final int FA_INCOME_FROM_FEES = 3;
|
||||
private static final int FA_SHARES_EQUITY = 4;
|
||||
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||
|
||||
/** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */
|
||||
private static final int PRODUCT_TYPE_SAVINGS = 2;
|
||||
private static final int FA_SAVINGS_CONTROL = 2;
|
||||
|
||||
private static final int SHARE_STATUS_ACTIVE = 300;
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
|
||||
/** m_payment_type.value seeded by changelog 0483 for this settlement. */
|
||||
private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
// ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ──────────────
|
||||
|
||||
/**
|
||||
* Whether this share account can be settled from savings, and for how much.
|
||||
*
|
||||
* Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked,
|
||||
* does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the
|
||||
* counter instead of after a request has been raised and has to be rejected.
|
||||
*/
|
||||
@GET
|
||||
@Path("/eligibility/{shareAccountId}")
|
||||
public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) {
|
||||
this.context.authenticatedUser();
|
||||
final Map<String, Object> acct = loadShareAccount(shareAccountId);
|
||||
|
||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
||||
final BigDecimal required = shareAmount.add(chargeAmount);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("shareAccountId", shareAccountId);
|
||||
out.put("shareAccountNo", acct.get("account_no"));
|
||||
out.put("shareAmount", shareAmount);
|
||||
out.put("chargeAmount", chargeAmount);
|
||||
out.put("totalRequired", required);
|
||||
|
||||
final Object savingsAccountIdRaw = acct.get("savings_account_id");
|
||||
if (savingsAccountIdRaw == null) {
|
||||
out.put("eligible", false);
|
||||
out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead.");
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
final Long savingsAccountId = toLong(savingsAccountIdRaw);
|
||||
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
|
||||
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
|
||||
final BigDecimal available = balance == null ? BigDecimal.ZERO : balance;
|
||||
|
||||
out.put("savingsAccountId", savingsAccountId);
|
||||
out.put("savingsAccountNo", savings.get("account_no"));
|
||||
out.put("savingsBalance", available);
|
||||
|
||||
final String blocker = settlementBlocker(acct, savings, required, available);
|
||||
out.put("eligible", blocker == null);
|
||||
out.put("reason", blocker);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this share account cannot be settled from savings right now, or null when it can.
|
||||
*
|
||||
* Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement
|
||||
* is allowed.
|
||||
*/
|
||||
private String settlementBlocker(final Map<String, Object> shareAccount, final Map<String, Object> savings,
|
||||
final BigDecimal required, final BigDecimal available) {
|
||||
|
||||
if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) {
|
||||
return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled.";
|
||||
}
|
||||
if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) {
|
||||
return "Share account " + shareAccount.get("account_no") + " has already been settled.";
|
||||
}
|
||||
if (required.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return "There is nothing outstanding on share account " + shareAccount.get("account_no") + ".";
|
||||
}
|
||||
// The savings account must belong to the same member — debiting anyone else's balance for
|
||||
// these shares would be taking one member's money for another's holding.
|
||||
if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) {
|
||||
return "The linked savings account belongs to a different member.";
|
||||
}
|
||||
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
|
||||
return "Savings account " + savings.get("account_no") + " is not Active.";
|
||||
}
|
||||
if (available.compareTo(required) < 0) {
|
||||
return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required
|
||||
+ " is required. Take a deposit first, or settle by cash collection.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── POST /v1/sharesavingsdebit ── Clerk records the request ─────────────
|
||||
|
||||
@POST
|
||||
@Transactional
|
||||
public Response submit(final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
final JsonObject json = parseBody(body);
|
||||
final Long shareAccountId = getLong(json, "shareAccountId");
|
||||
if (shareAccountId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required.");
|
||||
}
|
||||
|
||||
final Map<String, Object> acct = loadShareAccount(shareAccountId);
|
||||
final Object savingsAccountIdRaw = acct.get("savings_account_id");
|
||||
if (savingsAccountIdRaw == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings",
|
||||
"Share account " + acct.get("account_no") + " has no linked savings account.");
|
||||
}
|
||||
final Long savingsAccountId = toLong(savingsAccountIdRaw);
|
||||
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
|
||||
|
||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
||||
final BigDecimal required = shareAmount.add(chargeAmount);
|
||||
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
|
||||
|
||||
// Checked at submit as well as at approval: the clerk should be told now, not after the
|
||||
// member has left and a manager finds the account short.
|
||||
final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
|
||||
if (blocker != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
|
||||
}
|
||||
|
||||
final Long existingPending = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class,
|
||||
shareAccountId);
|
||||
if (existingPending != null && existingPending > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending",
|
||||
"A savings-debit request for this share account is already pending review.");
|
||||
}
|
||||
|
||||
final Long newId = this.jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_share_savings_debit_request
|
||||
(share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount,
|
||||
currency_code, status, clerk_id, clerk_name, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
|
||||
RETURNING id
|
||||
""", Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required,
|
||||
acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note"));
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", newId);
|
||||
out.put("status", "PENDING");
|
||||
out.put("totalAmount", required);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ── GET /v1/sharesavingsdebit ── Requests awaiting review ───────────────
|
||||
|
||||
@GET
|
||||
public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("shareAccountId") final Long shareAccountId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name,
|
||||
r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance,
|
||||
r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status,
|
||||
r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at,
|
||||
r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note
|
||||
FROM m_share_savings_debit_request r
|
||||
JOIN m_share_account sa ON sa.id = r.share_account_id
|
||||
JOIN m_client c ON c.id = r.client_id
|
||||
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
|
||||
WHERE 1 = 1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (!"ALL".equalsIgnoreCase(status)) {
|
||||
sql.append(" AND r.status = ? ");
|
||||
params.add(status.toUpperCase());
|
||||
}
|
||||
if (shareAccountId != null) {
|
||||
sql.append(" AND r.share_account_id = ? ");
|
||||
params.add(shareAccountId);
|
||||
}
|
||||
sql.append(" ORDER BY r.submitted_at DESC, r.id DESC");
|
||||
|
||||
return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build();
|
||||
}
|
||||
|
||||
// ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ─────────
|
||||
|
||||
/**
|
||||
* Debits the member's savings and issues the shares.
|
||||
*
|
||||
* Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the
|
||||
* approval is the point at which it actually matters.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{id}/approve")
|
||||
@Transactional
|
||||
public Response approve(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> req = loadRequest(id);
|
||||
if (!"PENDING".equals(req.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
|
||||
"Only PENDING requests can be approved. This one is " + req.get("status") + ".");
|
||||
}
|
||||
if (toLong(req.get("clerk_id")).equals(currentUser.getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval",
|
||||
"Maker-checker: you cannot approve a request you submitted yourself. Ask another manager.");
|
||||
}
|
||||
|
||||
final Long shareAccountId = toLong(req.get("share_account_id"));
|
||||
final Long savingsAccountId = toLong(req.get("savings_account_id"));
|
||||
final Map<String, Object> acct = loadShareAccount(shareAccountId);
|
||||
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
|
||||
|
||||
// Recomputed rather than taken from the request: shares or charges may have been edited
|
||||
// between submit and approval, and the member must be debited for what is actually owed.
|
||||
final BigDecimal shareAmount = shareValueOf(shareAccountId);
|
||||
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
|
||||
final BigDecimal required = shareAmount.add(chargeAmount);
|
||||
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
|
||||
|
||||
final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
|
||||
if (blocker != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
|
||||
}
|
||||
|
||||
final Long productId = toLong(acct.get("product_id"));
|
||||
final Long officeId = toLong(acct.get("office_id"));
|
||||
final String currencyCode = (String) acct.get("currency_code");
|
||||
final String accountNo = (String) acct.get("account_no");
|
||||
|
||||
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
|
||||
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
|
||||
if (glSavingsControl == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped",
|
||||
"The Savings Control GL is not mapped for this savings account's product.");
|
||||
}
|
||||
|
||||
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
|
||||
final String note = "Share application settled from savings | " + accountNo;
|
||||
|
||||
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
|
||||
// pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money
|
||||
// became share capital, which the pair below records.
|
||||
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note);
|
||||
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
|
||||
|
||||
final long userId = currentUser.getId();
|
||||
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final String txnId = "SHSAV-" + savingsTxnId;
|
||||
|
||||
// Routed through Share Sundry rather than straight from savings into share capital, in two
|
||||
// steps posted together:
|
||||
//
|
||||
// 1. Dr Savings Control / Cr Share Sundry — the money leaves the member
|
||||
// 2. Dr Share Sundry / Cr Share Capital (+ Cr Fee Income) — and is issued as shares
|
||||
//
|
||||
// Every other way money reaches share capital passes through Sundry — the cashier's advance
|
||||
// collection and both additional-share routes — so the Sundry balance is what the whole
|
||||
// share ledger reconciles against. A settlement that jumped the queue straight to capital
|
||||
// left that reconciliation permanently short by its own amount, and left no single account
|
||||
// where money taken for shares but not yet issued could be seen.
|
||||
final Long glSundry = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
|
||||
|
||||
insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share application debited from savings " + savings.get("account_no") + " | " + accountNo);
|
||||
insertEntry(officeId, glSundry, CREDIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share application held in Share Sundry | " + accountNo);
|
||||
|
||||
// The full amount comes back out of Sundry — share value to capital, entry fee to income —
|
||||
// so Sundry nets to zero for this settlement rather than retaining the fee.
|
||||
insertEntry(officeId, glSundry, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share Sundry released for " + accountNo);
|
||||
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share capital issued for " + accountNo);
|
||||
|
||||
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
||||
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||
"Share entry fee for " + accountNo);
|
||||
this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
||||
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
||||
}
|
||||
|
||||
// Marks the account settled, which is what stops it being settled again by either route.
|
||||
this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
|
||||
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_share_savings_debit_request
|
||||
SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(),
|
||||
share_amount = ?, charge_amount = ?, total_amount = ?,
|
||||
savings_transaction_id = ?, gl_transaction_id = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", id);
|
||||
out.put("status", "APPROVED");
|
||||
out.put("shareAmount", shareAmount);
|
||||
out.put("chargeAmount", chargeAmount);
|
||||
out.put("totalDebited", required);
|
||||
out.put("savingsTransactionId", savingsTxnId);
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ───────────
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reject")
|
||||
@Transactional
|
||||
public Response reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = this.context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
|
||||
final Map<String, Object> req = loadRequest(id);
|
||||
if (!"PENDING".equals(req.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
|
||||
"Only PENDING requests can be rejected. This one is " + req.get("status") + ".");
|
||||
}
|
||||
|
||||
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body);
|
||||
this.jdbcTemplate.update("""
|
||||
UPDATE m_share_savings_debit_request
|
||||
SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id);
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", id);
|
||||
out.put("status", "REJECTED");
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate,
|
||||
final String note) {
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
payload.addProperty("transactionDate", txnDate.toString());
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
|
||||
payload.addProperty("note", note);
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString())
|
||||
.build();
|
||||
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
return result.getResourceId();
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the
|
||||
* withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out.
|
||||
* The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it
|
||||
* on the member's statement and in payment-type reporting.
|
||||
*
|
||||
* Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles
|
||||
* rather than failing at approval with money already half-moved.
|
||||
*/
|
||||
private Long resolveSettlementPaymentTypeId() {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE);
|
||||
if (!rows.isEmpty()) {
|
||||
return toLong(rows.get(0).get("id"));
|
||||
}
|
||||
final List<Map<String, Object>> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1");
|
||||
if (fallback.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.payment.type",
|
||||
"No payment types are configured. Add one before settling a share application from savings.");
|
||||
}
|
||||
return toLong(fallback.get(0).get("id"));
|
||||
}
|
||||
|
||||
/** Savings Control GL for this savings account's product. */
|
||||
private Long savingsControlGlOf(final Long savingsAccountId) {
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||
SELECT apm.gl_account_id FROM m_savings_account sa
|
||||
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
|
||||
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
|
||||
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
|
||||
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
|
||||
}
|
||||
|
||||
/** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */
|
||||
private BigDecimal shareValueOf(final Long shareAccountId) {
|
||||
final BigDecimal total = this.jdbcTemplate
|
||||
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
|
||||
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
|
||||
return total == null ? BigDecimal.ZERO : total;
|
||||
}
|
||||
|
||||
/** Entry fee still outstanding, zero when no charge was attached. */
|
||||
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
|
||||
final BigDecimal total = this.jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge "
|
||||
+ " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0",
|
||||
BigDecimal.class, shareAccountId);
|
||||
return total == null ? BigDecimal.ZERO : total;
|
||||
}
|
||||
|
||||
private Map<String, Object> loadShareAccount(final Long shareAccountId) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForMap("""
|
||||
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code,
|
||||
sa.collection_applied, sa.savings_account_id, c.office_id
|
||||
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?
|
||||
""", shareAccountId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found",
|
||||
"Share account " + shareAccountId + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForMap(
|
||||
"SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?",
|
||||
savingsAccountId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found",
|
||||
"Savings account " + savingsAccountId + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> loadRequest(final Long id) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found",
|
||||
"Savings-debit request " + id + " does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
|
||||
try {
|
||||
final Long glId = this.jdbcTemplate.queryForObject(
|
||||
"SELECT gl_account_id FROM acc_product_mapping "
|
||||
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
|
||||
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
||||
if (glId == null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
|
||||
label + " GL is not mapped for share product " + productId + ".");
|
||||
}
|
||||
return glId;
|
||||
} catch (final IncorrectResultSizeDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
|
||||
label + " GL is not mapped for share product " + productId + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
|
||||
final String description) {
|
||||
this.jdbcTemplate.update(
|
||||
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||
+ " is_running_balance_calculated, office_running_balance, "
|
||||
+ " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) "
|
||||
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
|
||||
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
|
||||
}
|
||||
|
||||
/**
|
||||
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
|
||||
*
|
||||
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
|
||||
* 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's
|
||||
* own zone first, which is the zone {@code now} was taken in.
|
||||
*/
|
||||
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
|
||||
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
||||
.withOffsetSameInstant(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
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 new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required",
|
||||
"Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request.");
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject parseBody(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required.");
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (final RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
private Long getLong(final JsonObject json, final String field) {
|
||||
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
|
||||
}
|
||||
|
||||
private String getString(final JsonObject json, final String field) {
|
||||
if (!json.has(field) || json.get(field).isJsonNull()) {
|
||||
return null;
|
||||
}
|
||||
final String value = json.get(field).getAsString().trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
private int toInt(final Object o) {
|
||||
return o instanceof Number n ? n.intValue() : 0;
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -45,14 +45,9 @@ import org.springframework.stereotype.Component;
|
||||
* Can a member fund a <i>new</i> additional-share tranche from their savings, and for how much.
|
||||
*
|
||||
* <p>
|
||||
* Distinct from {@code /sharesavingsdebit/eligibility}, which answers the same question for an account's first
|
||||
* application: that endpoint values the settlement by summing every purchase row on the account and treats
|
||||
* {@code collection_applied} as the account's one settlement. Applied to a further tranche it would quote the member's
|
||||
* entire holding history and report an already-settled account as ineligible.
|
||||
*
|
||||
* <p>
|
||||
* Here the amount is the caller's — the tranche is not saved yet, so there is no row to read it from. The share count is
|
||||
* passed in and priced at the product's current market price, which is the same figure the apply form shows the member.
|
||||
* Deliberately scoped to a single tranche rather than the account's whole holding history: the amount here is the
|
||||
* caller's — the tranche is not saved yet, so there is no row to read it from. The share count is passed in and
|
||||
* priced at the product's current market price, which is the same figure the apply form shows the member.
|
||||
*/
|
||||
@Path("/v1/shareaccounts/{accountId}/additionalshares")
|
||||
@Component
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 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.shareaccounts.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.springframework.dao.EmptyResultDataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Additional-share tranches awaiting approval, across every share account, for a centralized Approval Center queue.
|
||||
*
|
||||
* <p>
|
||||
* A tranche raised through "Apply Additional Shares" today is only visible for approval on its own account's page
|
||||
* (the native {@code approveadditionalshares}/{@code rejectadditionalshares} commands, invoked directly from a button
|
||||
* there) — there was no single place a checker could see every tranche waiting across the whole membership. This
|
||||
* resource is the read side of that queue: one list of every pending tranche, and one detail view per tranche with
|
||||
* everything a checker needs to decide, including the route-specific proof of funding a list row cannot show — which
|
||||
* voucher(s) a cash tranche is resting on, or which savings account a savings tranche already debited.
|
||||
*
|
||||
* <p>
|
||||
* Approval and rejection themselves are NOT duplicated here — they still go through Fineract's own
|
||||
* {@code approveadditionalshares}/{@code rejectadditionalshares} commands (same as the account-page buttons), so the
|
||||
* journal postings and {@link org.apache.fineract.portfolio.shareaccounts.service.AdditionalShareTrancheFundingService}
|
||||
* settle/unwind hooks that already run on those commands run exactly once, from exactly one code path.
|
||||
*/
|
||||
@Path("/v1/shareadditionrequests")
|
||||
@Component
|
||||
@Tag(name = "Share Addition Requests", description = "Additional-share tranches awaiting approval, across every share account.")
|
||||
@RequiredArgsConstructor
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ShareAdditionRequestsApiResource {
|
||||
|
||||
/** m_share_account_transactions.status_enum for "submitted, awaiting approval". */
|
||||
private static final int STATUS_APPLIED = 100;
|
||||
/** m_share_account_transactions.type_enum for a share purchase (an original application or a tranche alike). */
|
||||
private static final int TYPE_PURCHASED = 500;
|
||||
|
||||
private static final String FUNDING_CASH = "CASH";
|
||||
private static final String FUNDING_SAVINGS = "SAVINGS";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// Every alias is double-quoted: PostgreSQL folds an unquoted alias to lower case, so
|
||||
// queryForList would key the maps "shareaccountid" etc., which serialise straight into JSON and
|
||||
// leave the client reading shareAccountId as undefined.
|
||||
private static final String BASE_SELECT = """
|
||||
SELECT t.id AS "transactionId",
|
||||
sa.id AS "shareAccountId",
|
||||
sa.account_no AS "accountNo",
|
||||
sa.client_id AS "clientId",
|
||||
c.display_name AS "clientName",
|
||||
o.name AS "officeName",
|
||||
sp.name AS "productName",
|
||||
sa.currency_code AS "currencyCode",
|
||||
t.transaction_date AS "transactionDate",
|
||||
t.total_shares AS "totalShares",
|
||||
t.unit_price AS "unitPrice",
|
||||
t.amount AS "amount",
|
||||
t.charge_amount AS "chargeAmount",
|
||||
t.resolution_number AS "resolutionNumber",
|
||||
t.meeting_date AS "meetingDate",
|
||||
t.funding_source AS "fundingSource",
|
||||
t.advance_collection_id AS "advanceCollectionId",
|
||||
t.additional_collection_ids AS "additionalCollectionIds"
|
||||
FROM m_share_account_transactions t
|
||||
JOIN m_share_account sa ON sa.id = t.account_id
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
JOIN m_share_product sp ON sp.id = sa.product_id
|
||||
""";
|
||||
|
||||
@GET
|
||||
@Operation(summary = "List additional-share tranches awaiting approval",
|
||||
description = "Every pending tranche across every share account, with the member, account, shares and value a checker needs to see before deciding.")
|
||||
public Response retrievePending() {
|
||||
this.context.authenticatedUser();
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
BASE_SELECT + " WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = ? "
|
||||
+ " ORDER BY t.transaction_date, t.id",
|
||||
STATUS_APPLIED, TYPE_PURCHASED);
|
||||
return Response.ok(gson.toJson(rows)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{transactionId}")
|
||||
@Operation(summary = "Full detail for one pending tranche",
|
||||
description = "The list row plus route-specific proof of funding: the voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche.")
|
||||
public Response retrieveOne(@PathParam("transactionId") final Long transactionId) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final Map<String, Object> row;
|
||||
try {
|
||||
row = this.jdbcTemplate.queryForMap(BASE_SELECT + " WHERE t.id = ?", transactionId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.shareaddition.not.found",
|
||||
"Additional-share request " + transactionId + " does not exist.");
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>(row);
|
||||
final String fundingSource = (String) row.get("fundingSource");
|
||||
|
||||
if (FUNDING_CASH.equals(fundingSource)) {
|
||||
out.put("vouchers", vouchersFor(row));
|
||||
} else if (FUNDING_SAVINGS.equals(fundingSource)) {
|
||||
out.put("savingsAccount", savingsAccountFor(toLong(row.get("shareAccountId"))));
|
||||
}
|
||||
|
||||
return Response.ok(gson.toJson(out)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every receipt funding a cash tranche — the lead voucher plus any further instalments — so a checker sees the
|
||||
* whole pot behind the amount rather than just the first receipt.
|
||||
*/
|
||||
private List<Map<String, Object>> vouchersFor(final Map<String, Object> row) {
|
||||
final List<Long> ids = new ArrayList<>();
|
||||
final Long lead = toLong(row.get("advanceCollectionId"));
|
||||
if (lead != null) {
|
||||
ids.add(lead);
|
||||
}
|
||||
final String additional = (String) row.get("additionalCollectionIds");
|
||||
if (additional != null && !additional.isBlank()) {
|
||||
for (final String part : additional.split(",")) {
|
||||
final String trimmed = part.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
ids.add(Long.valueOf(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
final String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
|
||||
return this.jdbcTemplate.queryForList("""
|
||||
SELECT id, receipt_no AS "receiptNo", voucher_no AS "voucherNo", amount, collection_date AS "collectionDate",
|
||||
collected_by_name AS "collectedByName", status
|
||||
FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id
|
||||
""".formatted(placeholders), ids.toArray());
|
||||
}
|
||||
|
||||
/** The savings account a savings-route tranche already debited, so a checker can see it was real money. */
|
||||
private Map<String, Object> savingsAccountFor(final Long shareAccountId) {
|
||||
try {
|
||||
return this.jdbcTemplate.queryForMap("""
|
||||
SELECT sav.id, sav.account_no AS "accountNo", sav.account_balance_derived AS "balance"
|
||||
FROM m_savings_account sav
|
||||
JOIN m_share_account sa ON sa.savings_account_id = sav.id
|
||||
WHERE sa.id = ?
|
||||
""", shareAccountId);
|
||||
} catch (final EmptyResultDataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Long toLong(final Object o) {
|
||||
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -658,17 +658,16 @@ public class ShareAccountDataSerializer {
|
||||
|
||||
private void handlechargesOnActivation(final ShareAccount account) {
|
||||
// The entry fee is deliberately NOT marked paid here. Activating the account decides the
|
||||
// member may hold shares; it does not collect anything, and nobody has yet said where the
|
||||
// money comes from — a cash receipt at the counter, or a debit from the member's savings.
|
||||
// member may hold shares; it does not collect anything, and nobody has yet said the cash
|
||||
// receipt at the counter has actually been taken.
|
||||
//
|
||||
// Marking it paid at activation left amount_paid_derived at the full fee and
|
||||
// amount_outstanding_derived at zero while nothing had actually been received, so the
|
||||
// settlement step read the fee as already settled and asked the member for the share value
|
||||
// alone ("shares 400.00 + charges 0"). The fee was then never collected at all.
|
||||
//
|
||||
// It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource
|
||||
// (cash receipt) or ShareSavingsDebitRequestApiResource (savings debit), both of which set
|
||||
// amount_paid_derived and amount_outstanding_derived alongside the flag.
|
||||
// It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource,
|
||||
// which sets amount_paid_derived and amount_outstanding_derived alongside the flag.
|
||||
//
|
||||
// The charge transaction is still dated to the activation date — that records when the fee
|
||||
// became due, which is a separate thing from when it was paid.
|
||||
|
||||
+5
-6
@@ -47,12 +47,11 @@ import org.springframework.stereotype.Component;
|
||||
* account, where it waits for the tranche to be approved.
|
||||
*
|
||||
* <p>
|
||||
* <b>Why this is separate from the account-level settlement resources.</b> {@code ShareAdvanceCollectionApiResource} and
|
||||
* {@code ShareSavingsDebitRequestApiResource} settle an account's <i>first</i> application. Both value the settlement by
|
||||
* summing every purchase row on the account, and both guard with account-wide state — {@code collection_applied}, and
|
||||
* one-pending-request-per-account. Neither assumption survives a second tranche: the valuation would charge the member
|
||||
* for their entire holding history, and the guard would refuse the request outright. So tranche funding is valued and
|
||||
* recorded per transaction, and leaves that account-level state alone.
|
||||
* <b>Why this is separate from the account-level settlement resource.</b> {@code ShareAdvanceCollectionApiResource}
|
||||
* settles an account's <i>first</i> application. It values the settlement by summing every purchase row on the
|
||||
* account, and guards with account-wide state — {@code collection_applied} — that assumption does not survive a
|
||||
* second tranche: the valuation would charge the member for their entire holding history. So tranche funding is
|
||||
* valued and recorded per transaction, and leaves that account-level state alone.
|
||||
*
|
||||
* <p>
|
||||
* <b>Where the money ends up.</b> Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts
|
||||
|
||||
+6
-8
@@ -268,9 +268,8 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
||||
// shares; it does not collect anything from them. Posting Dr Share Sundry / Cr Share
|
||||
// Capital at this point issues share capital against a Sundry balance that holds no
|
||||
// receipt for it — in effect funding one member's shares out of money other members
|
||||
// paid in. The entries belong to settlement, where the money actually arrives: either
|
||||
// applying the member's cash receipt (ShareAdvanceCollectionApiResource) or debiting
|
||||
// their savings (ShareSavingsDebitRequestApiResource).
|
||||
// paid in. The entries belong to settlement, where the money actually arrives: applying
|
||||
// the member's cash receipt (ShareAdvanceCollectionApiResource).
|
||||
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account));
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
@@ -367,15 +366,14 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
||||
|
||||
// Charges are deliberately NOT marked paid here. Attaching an entry fee when the
|
||||
// account is created records what the member owes; activating the account decides they
|
||||
// may hold shares. Neither collects anything, and nobody has yet said where the money
|
||||
// is coming from — a cash receipt at the counter, or a debit from the member's savings.
|
||||
// may hold shares. Neither collects anything, and nobody has yet said the cash receipt
|
||||
// at the counter has actually been taken.
|
||||
//
|
||||
// Marking it paid at activation left the fee showing as settled while it was still
|
||||
// outstanding, so the settlement step then asked the member for the share value alone
|
||||
// and the fee was never collected at all. It is marked paid where the money actually
|
||||
// arrives: ShareAdvanceCollectionApiResource (cash receipt) or
|
||||
// ShareSavingsDebitRequestApiResource (savings debit), both of which set
|
||||
// amount_paid_derived and amount_outstanding_derived alongside the flag.
|
||||
// arrives: ShareAdvanceCollectionApiResource, which sets amount_paid_derived and
|
||||
// amount_outstanding_derived alongside the flag.
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(jsonCommand.commandId()) //
|
||||
|
||||
@@ -46,4 +46,5 @@
|
||||
<include file="parts/0517_create_savings_daily_interest_ledger.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0518_create_savings_interest_posting_schedule.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0519_post_savings_interest_at_period_end.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0520_drop_share_savings_debit_request.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Removes the "Debit from Savings" settlement route for a share account's first application
|
||||
(ShareSavingsDebitRequestApiResource, table created in 0482). Never used in production — zero
|
||||
rows on either live tenant since the feature was built — and superseded in spirit by the
|
||||
Additional Shares tranche funding's own savings route, which the same use case is better
|
||||
served by (that one is valued per tranche rather than by summing the account's whole holding
|
||||
history). No other table references this one, so dropping it removes no data anyone depends
|
||||
on.
|
||||
-->
|
||||
<changeSet id="0520_drop_share_savings_debit_request" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_share_savings_debit_request"/>
|
||||
</preConditions>
|
||||
<dropTable tableName="m_share_savings_debit_request"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user