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

- 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:
2026-08-30 20:37:33 +05:30
co-authored by Claude Sonnet 5
parent 8ecab08d57
commit 1130a482a0
8 changed files with 255 additions and 656 deletions
@@ -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;
}
}
@@ -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
@@ -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;
}
}
@@ -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.
@@ -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
@@ -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>
@@ -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>