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.
|
* Can a member fund a <i>new</i> additional-share tranche from their savings, and for how much.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Distinct from {@code /sharesavingsdebit/eligibility}, which answers the same question for an account's first
|
* Deliberately scoped to a single tranche rather than the account's whole holding history: the amount here is the
|
||||||
* application: that endpoint values the settlement by summing every purchase row on the account and treats
|
* caller's — the tranche is not saved yet, so there is no row to read it from. The share count is passed in and
|
||||||
* {@code collection_applied} as the account's one settlement. Applied to a further tranche it would quote the member's
|
* priced at the product's current market price, which is the same figure the apply form shows the member.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Path("/v1/shareaccounts/{accountId}/additionalshares")
|
@Path("/v1/shareaccounts/{accountId}/additionalshares")
|
||||||
@Component
|
@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) {
|
private void handlechargesOnActivation(final ShareAccount account) {
|
||||||
// The entry fee is deliberately NOT marked paid here. Activating the account decides the
|
// 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
|
// member may hold shares; it does not collect anything, and nobody has yet said the cash
|
||||||
// money comes from — a cash receipt at the counter, or a debit from the member's savings.
|
// receipt at the counter has actually been taken.
|
||||||
//
|
//
|
||||||
// Marking it paid at activation left amount_paid_derived at the full fee and
|
// 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
|
// 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
|
// 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.
|
// alone ("shares 400.00 + charges 0"). The fee was then never collected at all.
|
||||||
//
|
//
|
||||||
// It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource
|
// It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource,
|
||||||
// (cash receipt) or ShareSavingsDebitRequestApiResource (savings debit), both of which set
|
// which sets amount_paid_derived and amount_outstanding_derived alongside the flag.
|
||||||
// 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
|
// 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.
|
// 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.
|
* account, where it waits for the tranche to be approved.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* <b>Why this is separate from the account-level settlement resources.</b> {@code ShareAdvanceCollectionApiResource} and
|
* <b>Why this is separate from the account-level settlement resource.</b> {@code ShareAdvanceCollectionApiResource}
|
||||||
* {@code ShareSavingsDebitRequestApiResource} settle an account's <i>first</i> application. Both value the settlement by
|
* settles an account's <i>first</i> application. It values the settlement by summing every purchase row on the
|
||||||
* summing every purchase row on the account, and both guard with account-wide state — {@code collection_applied}, and
|
* account, and guards with account-wide state — {@code collection_applied} — that assumption does not survive a
|
||||||
* one-pending-request-per-account. Neither assumption survives a second tranche: the valuation would charge the member
|
* second tranche: the valuation would charge the member for their entire holding history. So tranche funding is
|
||||||
* for their entire holding history, and the guard would refuse the request outright. So tranche funding is valued and
|
* valued and recorded per transaction, and leaves that account-level state alone.
|
||||||
* recorded per transaction, and leaves that account-level state alone.
|
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* <b>Where the money ends up.</b> Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts
|
* <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
|
// 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
|
// 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
|
// 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
|
// paid in. The entries belong to settlement, where the money actually arrives: applying
|
||||||
// applying the member's cash receipt (ShareAdvanceCollectionApiResource) or debiting
|
// the member's cash receipt (ShareAdvanceCollectionApiResource).
|
||||||
// their savings (ShareSavingsDebitRequestApiResource).
|
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account));
|
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account));
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
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
|
// 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
|
// 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
|
// may hold shares. Neither collects anything, and nobody has yet said the cash receipt
|
||||||
// is coming from — a cash receipt at the counter, or a debit from the member's savings.
|
// at the counter has actually been taken.
|
||||||
//
|
//
|
||||||
// Marking it paid at activation left the fee showing as settled while it was still
|
// 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
|
// 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
|
// and the fee was never collected at all. It is marked paid where the money actually
|
||||||
// arrives: ShareAdvanceCollectionApiResource (cash receipt) or
|
// arrives: ShareAdvanceCollectionApiResource, which sets amount_paid_derived and
|
||||||
// ShareSavingsDebitRequestApiResource (savings debit), both of which set
|
// amount_outstanding_derived alongside the flag.
|
||||||
// amount_paid_derived and amount_outstanding_derived alongside the flag.
|
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(jsonCommand.commandId()) //
|
.withCommandId(jsonCommand.commandId()) //
|
||||||
|
|||||||
@@ -46,4 +46,5 @@
|
|||||||
<include file="parts/0517_create_savings_daily_interest_ledger.xml" relativeToChangelogFile="true"/>
|
<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/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/0519_post_savings_interest_at_period_end.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0520_drop_share_savings_debit_request.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</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