implement share account actions, view components, and pending checker tasks
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (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 Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract 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 - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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 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

This commit is contained in:
2026-08-27 10:27:58 +05:30
parent 72221f704d
commit 179fe3a6ce
16 changed files with 1335 additions and 10 deletions
@@ -113,6 +113,37 @@ public interface ShareAccountApiConstants {
* outstanding balance. */
String linkedloanid_paramname = "linkedLoanId";
/**
* Where the money for an additional-share tranche comes from — {@code CASH} or {@code SAVINGS}.
*
* Both routes leave the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
* approving the tranche posts the same Dr Sundry / Cr Share Capital pair either way. They
* differ only in how it got there: CASH means the cashier already took it at the counter
* against a voucher, SAVINGS means the member's own savings account is debited on submit.
*/
String fundingsource_paramname = "fundingSource";
String FUNDING_SOURCE_CASH = "CASH";
String FUNDING_SOURCE_SAVINGS = "SAVINGS";
/**
* The Share Sundry receipt funding a CASH tranche, from {@code m_share_advance_collection}.
*
* Recorded against the tranche so one voucher cannot be spent twice, and so the approver can
* see which receipt the money they are releasing came in on.
*/
String advancecollectionid_paramname = "advanceCollectionId";
/**
* The remaining vouchers funding a CASH tranche, when one receipt does not cover it.
*
* A member paying in instalments — ₹600 today, the ₹400 balance tomorrow — has several receipts
* against one request. The first leads in {@link #advancecollectionid_paramname}; these are the
* rest, and their combined total is what has to cover the shares.
*/
String additionalcollectionids_paramname = "additionalCollectionIds";
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
@@ -65,11 +65,17 @@ import org.springframework.transaction.annotation.Transactional;
*
* <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>
*
* No Sundry leg, because nothing is ever held — the debit and the share issue happen together.
* 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
@@ -93,6 +99,8 @@ public class ShareSavingsDebitRequestApiResource {
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;
@@ -350,8 +358,28 @@ public class ShareSavingsDebitRequestApiResource {
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);
@@ -0,0 +1,183 @@
/**
* 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.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
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;
/**
* 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.
*/
@Path("/v1/shareaccounts/{accountId}/additionalshares")
@Component
@Tag(name = "Additional Shares Funding", description = "Whether a new additional-share tranche can be funded from the member's linked savings.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class AdditionalSharesFundingApiResource {
private static final int SAVINGS_STATUS_ACTIVE = 300;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/**
* @param requestedShares
* how many shares the member wants to buy; the amount is derived from this rather than trusted from the
* caller, so the eligibility answer and the tranche's own value cannot disagree.
*/
@GET
@Path("/savings-eligibility")
@Operation(summary = "Savings eligibility for a new additional-share tranche", description = "Reports the tranche's value and whether the member's linked savings account can cover it.")
public Response savingsEligibility(@PathParam("accountId") final Long accountId,
@QueryParam("requestedShares") final Long requestedShares) {
this.context.authenticatedUser();
final Map<String, Object> acct = loadShareAccount(accountId);
final BigDecimal unitPrice = currentMarketPriceOf(acct.get("product_id"));
final long shares = requestedShares == null ? 0L : requestedShares;
final BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(shares));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("shareAccountId", accountId);
out.put("requestedShares", shares);
out.put("unitPrice", unitPrice);
out.put("amount", amount);
final Object savingsAccountIdRaw = acct.get("savings_account_id");
if (savingsAccountIdRaw == null) {
return ineligible(out, null, null, "This share account has no linked savings account.");
}
final Map<String, Object> savings = loadSavingsAccount(toLong(savingsAccountIdRaw));
final String savingsAccountNo = (String) savings.get("account_no");
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
out.put("savingsAccountNo", savingsAccountNo);
out.put("savingsBalance", balance);
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
return ineligible(out, savingsAccountNo, balance, "Savings account " + savingsAccountNo + " is not active.");
}
// Zero shares is not a shortfall — the clerk simply has not typed the count yet, and saying
// the balance is short would be wrong as well as unhelpful.
if (shares <= 0) {
return ineligible(out, savingsAccountNo, balance, "Enter the number of shares to check the balance against.");
}
if (balance == null || balance.compareTo(amount) < 0) {
return ineligible(out, savingsAccountNo, balance,
"Savings account " + savingsAccountNo + " does not hold enough to cover this share request.");
}
out.put("eligible", Boolean.TRUE);
out.put("reason", null);
return Response.ok(gson.toJson(out)).build();
}
private Response ineligible(final Map<String, Object> out, final String savingsAccountNo, final BigDecimal balance,
final String reason) {
out.putIfAbsent("savingsAccountNo", savingsAccountNo);
out.putIfAbsent("savingsBalance", balance);
out.put("eligible", Boolean.FALSE);
out.put("reason", reason);
return Response.ok(gson.toJson(out)).build();
}
/**
* The product's price on the latest market-price period that has already started, matching how
* {@code ShareProduct.deriveMarketPrice} chooses one; the product's own unit price when no period applies.
*/
private BigDecimal currentMarketPriceOf(final Object productIdRaw) {
final Long productId = toLong(productIdRaw);
final List<Map<String, Object>> periods = this.jdbcTemplate.queryForList("""
SELECT share_value FROM m_share_product_market_price
WHERE product_id = ? AND from_date <= CURRENT_DATE
ORDER BY from_date DESC LIMIT 1
""", productId);
if (!periods.isEmpty()) {
final BigDecimal periodPrice = (BigDecimal) periods.get(0).get("share_value");
if (periodPrice != null) {
return periodPrice;
}
}
final BigDecimal unitPrice = this.jdbcTemplate.queryForObject("SELECT unit_price FROM m_share_product WHERE id = ?",
BigDecimal.class, productId);
return unitPrice == null ? BigDecimal.ZERO : unitPrice;
}
private Map<String, Object> loadShareAccount(final Long accountId) {
try {
return this.jdbcTemplate.queryForMap(
"SELECT id, account_no, client_id, product_id, status_enum, currency_code, savings_account_id "
+ " FROM m_share_account WHERE id = ?",
accountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.share.account.not.found",
"Share account " + accountId + " does not exist.");
}
}
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
try {
return this.jdbcTemplate.queryForMap(
"SELECT id, account_no, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", savingsAccountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.share.savings.account.not.found",
"Savings account " + savingsAccountId + " does not exist.");
}
}
private static Long toLong(final Object value) {
return value == null ? null : ((Number) value).longValue();
}
private static int toInt(final Object value) {
return value == null ? 0 : ((Number) value).intValue();
}
}
@@ -0,0 +1,107 @@
/**
* 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.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Share accounts awaiting approval, for the checker's Pending Share Accounts queue.
*
* <p>
* Separate from {@code /accounts/share}, whose {@code retrieveAll} is hard-wired to ACTIVE accounts and returns the full
* account graph — charges, every purchase transaction — per row. A queue needs the opposite: only accounts in
* "submitted and pending approval", and only the handful of fields a checker decides on, so the list stays one cheap
* query however many accounts are waiting.
*/
@Path("/v1/shareaccounts/pending")
@Component
@Tag(name = "Pending Share Accounts", description = "Share accounts submitted and awaiting a checker's approval.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class PendingShareAccountsApiResource {
/** m_share_account.status_enum for "submitted and pending approval". */
private static final int STATUS_SUBMITTED_AND_PENDING_APPROVAL = 100;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Operation(summary = "List share accounts awaiting approval", description = "Accounts in submitted-and-pending-approval, with the member, product, requested shares and value a checker needs to decide.")
public Response retrievePending() {
this.context.authenticatedUser();
// The requested shares come from the account's own pending purchase rows rather than
// total_pending_shares, and are summed: an account can carry more than one pending purchase,
// and the checker is approving the account, so they need to see the whole subscription.
//
// Every alias is double-quoted. PostgreSQL folds an unquoted alias to lower case, so
// queryForList would key the maps "accountno" and "shareaccountid" — which serialise straight
// into the JSON and leave the client reading accountNo/shareAccountId as undefined.
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT 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",
sa.submitted_date AS "submittedDate",
sa.resolution_number AS "resolutionNumber",
sa.resolution_date AS "resolutionDate",
au.username AS "submittedByUsername",
COALESCE(txn.requested_shares, 0) AS "requestedShares",
COALESCE(txn.requested_amount, 0) AS "requestedAmount"
FROM m_share_account sa
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
LEFT JOIN m_appuser au ON au.id = sa.submitted_userid
LEFT JOIN (
SELECT t.account_id,
SUM(t.total_shares) AS requested_shares,
SUM(t.total_shares * t.unit_price) AS requested_amount
FROM m_share_account_transactions t
WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = 500
GROUP BY t.account_id
) txn ON txn.account_id = sa.id
WHERE sa.status_enum = ?
ORDER BY sa.submitted_date DESC, sa.id DESC
""", STATUS_SUBMITTED_AND_PENDING_APPROVAL, STATUS_SUBMITTED_AND_PENDING_APPROVAL);
return Response.ok(gson.toJson(rows)).build();
}
}
@@ -51,10 +51,23 @@ public class ShareAccountTransactionData implements Serializable {
private final BigDecimal amountPaid;
/** Board Resolution Number / Date of Meeting that authorised this transaction — populated
* only for a redeem (withdrawal) transaction; null otherwise. */
/** Board Resolution Number / Date of Meeting that authorised this transaction — recorded on a
* redeem (withdrawal) and on an additional-share purchase; null otherwise. */
private final String resolutionNumber;
private final LocalDate meetingDate;
/** How an additional-share tranche was paid for — {@code CASH} or {@code SAVINGS}; null on a
* first application, and on tranches raised before the route was captured. Shown to whoever
* approves the tranche, since approving is what releases the money out of Share Sundry. */
private final String fundingSource;
/** The lead voucher funding a CASH tranche; the rest are in {@link #additionalCollectionIds}. */
private final Long advanceCollectionId;
/** Further vouchers funding the same tranche, comma-separated; null when one receipt covered it. */
private final String additionalCollectionIds;
private final Long savingsDebitRequestId;
}
@@ -28,8 +28,11 @@ import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@@ -75,6 +78,36 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
@Column(name = "meeting_date")
private LocalDate meetingDate;
/**
* How this tranche was paid for — {@code CASH} or {@code SAVINGS}; null on tranches raised
* before the route was captured.
*
* Both routes park the money in the Share Sundry (SHARES_SUSPENSE) holding account, so
* approving posts the same Dr Sundry / Cr Share Capital pair either way. The route matters on
* the way back out: rejecting a cash tranche releases the voucher to be spent again, while
* rejecting a savings tranche has to refund the member.
*/
@Column(name = "funding_source")
private String fundingSource;
/**
* The lead {@code m_share_advance_collection} receipt funding a CASH tranche.
*
* "Lead" because a member may pay one tranche in instalments; the rest are in
* {@link #additionalCollectionIds}. Read them together via {@link #getAllAdvanceCollectionIds()}
* rather than this field alone, or the later instalments are missed.
*/
@Column(name = "advance_collection_id")
private Long advanceCollectionId;
/** The remaining receipts funding this tranche, comma-separated; null when a single one covers it. */
@Column(name = "additional_collection_ids")
private String additionalCollectionIds;
/** The {@code m_share_savings_debit_request} behind a SAVINGS tranche. */
@Column(name = "savings_debit_request_id")
private Long savingsDebitRequestId;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "shareAccountTransaction", orphanRemoval = true, fetch = FetchType.EAGER)
private Set<ShareAccountChargePaidBy> shareAccountChargesPaid = new HashSet<>();
@@ -125,6 +158,79 @@ public class ShareAccountTransaction extends AbstractPersistableCustom<Long> {
return transaction;
}
/**
* Records the board resolution authorising a purchase, and how the tranche is funded.
*
* A purchase tranche carries its own resolution, not the account's: each further subscription
* is authorised by its own board meeting. Kept separate from the constructor because a first
* application has no funding route to record — only additional shares are raised this way.
*/
public void updatePurchaseAuthorisationAndFunding(final String resolutionNumber, final LocalDate meetingDate,
final String fundingSource, final List<Long> advanceCollectionIds, final Long savingsDebitRequestId) {
this.resolutionNumber = resolutionNumber;
this.meetingDate = meetingDate;
this.fundingSource = fundingSource;
this.savingsDebitRequestId = savingsDebitRequestId;
// First receipt leads; the rest ride along in a single column. Existing single-voucher rows
// keep exactly the shape they had, so nothing reading advance_collection_id has to change.
if (advanceCollectionIds == null || advanceCollectionIds.isEmpty()) {
this.advanceCollectionId = null;
this.additionalCollectionIds = null;
} else {
this.advanceCollectionId = advanceCollectionIds.get(0);
this.additionalCollectionIds = advanceCollectionIds.size() > 1
? advanceCollectionIds.subList(1, advanceCollectionIds.size()).stream().map(String::valueOf)
.collect(Collectors.joining(","))
: null;
}
}
public String getFundingSource() {
return this.fundingSource;
}
public Long getAdvanceCollectionId() {
return this.advanceCollectionId;
}
public String getAdditionalCollectionIds() {
return this.additionalCollectionIds;
}
/**
* Every receipt funding this tranche, lead first.
*
* The one method that should be used to act on a tranche's vouchers — reserving them, releasing
* them, marking them applied. Using {@code advanceCollectionId} alone would silently skip a
* member's later instalments, leaving their money reserved against nothing or spendable twice.
*/
public List<Long> getAllAdvanceCollectionIds() {
final List<Long> ids = new ArrayList<>();
if (this.advanceCollectionId != null) {
ids.add(this.advanceCollectionId);
}
if (this.additionalCollectionIds != null && !this.additionalCollectionIds.isBlank()) {
for (final String part : this.additionalCollectionIds.split(",")) {
final String trimmed = part.trim();
if (!trimmed.isEmpty()) {
final Long id = Long.valueOf(trimmed);
if (!ids.contains(id)) {
ids.add(id);
}
}
}
}
return ids;
}
public Long getSavingsDebitRequestId() {
return this.savingsDebitRequestId;
}
public void updateSavingsDebitRequestId(final Long savingsDebitRequestId) {
this.savingsDebitRequestId = savingsDebitRequestId;
}
public String getResolutionNumber() {
return this.resolutionNumber;
}
@@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.apache.fineract.portfolio.savings.exception.SavingsAccountNotFoundException;
import org.apache.fineract.portfolio.savings.service.SavingsAccountReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.domain.PurchasedSharesStatusType;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountCharge;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountChargePaidBy;
@@ -110,7 +111,9 @@ public class ShareAccountDataSerializer {
private static final Set<String> addtionalSharesParameters = new HashSet<>(Arrays.asList(ShareAccountApiConstants.locale_paramname,
ShareAccountApiConstants.requesteddate_paramname, ShareAccountApiConstants.requestedshares_paramname,
ShareAccountApiConstants.purchasedprice_paramname, ShareAccountApiConstants.dateformat_paramname,
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname));
ShareAccountApiConstants.redeemresolutionnumber_paramname, ShareAccountApiConstants.redeemmeetingdate_paramname,
ShareAccountApiConstants.fundingsource_paramname, ShareAccountApiConstants.advancecollectionid_paramname,
ShareAccountApiConstants.additionalcollectionids_paramname));
@Autowired
public ShareAccountDataSerializer(final PlatformSecurityContext platformSecurityContext, final FromJsonHelper fromApiJsonHelper,
@@ -746,17 +749,165 @@ public class ShareAccountDataSerializer {
.failWithCodeNoParameterAddedToErrorCode("purchase.transaction.date.cannot.be.before.existing.transactions");
}
// The board resolution authorising this tranche. Its own meeting and number: a further
// subscription is a separate decision from the one the account was opened under, so it
// cannot inherit the account's resolution.
final String resolutionNumber = this.fromApiJsonHelper
.extractStringNamed(ShareAccountApiConstants.redeemresolutionnumber_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemresolutionnumber_paramname).value(resolutionNumber).notBlank();
final LocalDate meetingDate = this.fromApiJsonHelper
.extractLocalDateNamed(ShareAccountApiConstants.redeemmeetingdate_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.redeemmeetingdate_paramname).value(meetingDate).notNull();
final String fundingSource = this.fromApiJsonHelper.extractStringNamed(ShareAccountApiConstants.fundingsource_paramname, element);
// The List overload, not the varargs one: the varargs version lowercases the incoming value
// but compares it against the un-lowercased allowed list, so an upper-case value can never
// match it. The List overload lowercases both sides.
baseDataValidator.reset().parameter(ShareAccountApiConstants.fundingsource_paramname).value(fundingSource).notBlank()
.isOneOfTheseStringValues(List.of(ShareAccountApiConstants.FUNDING_SOURCE_CASH,
ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS));
// On the cash route the money is already in Share Sundry against receipts the cashier
// issued, so the tranche has to name them. Validated here rather than at approval: a voucher
// problem found later is found once the member has gone.
//
// A list, not one id: a member often pays a tranche in instalments, and no single receipt
// covers it. They are validated as one settlement, so their combined total is what has to
// cover the shares.
List<Long> advanceCollectionIds = List.of();
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
advanceCollectionIds = extractAdvanceCollectionIds(element);
if (advanceCollectionIds.isEmpty()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull();
} else {
validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator);
}
}
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
final BigDecimal unitPrice = shareProduct.deriveMarketPrice(requestedDate);
ShareAccountTransaction purchaseTransaction = new ShareAccountTransaction(requestedDate, sharesRequested, unitPrice);
purchaseTransaction.updatePurchaseAuthorisationAndFunding(resolutionNumber, meetingDate, fundingSource, advanceCollectionIds, null);
account.addAdditionalPurchasedShares(purchaseTransaction);
handleAdditionalSharesChargeTransactions(account, purchaseTransaction);
actualChanges.put(ShareAccountApiConstants.additionalshares_paramname, purchaseTransaction);
return actualChanges;
}
/**
* The receipts a cash-funded tranche names — the lead one plus any further instalments.
*
* Accepts either shape, so a single-voucher caller need not send a list: {@code advanceCollectionId}
* alone, or that plus {@code additionalCollectionIds}.
*/
private List<Long> extractAdvanceCollectionIds(final JsonElement element) {
final List<Long> ids = new ArrayList<>();
final Long leadId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.advancecollectionid_paramname, element);
if (leadId != null) {
ids.add(leadId);
}
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.additionalcollectionids_paramname, element)) {
final JsonArray array = this.fromApiJsonHelper
.extractJsonArrayNamed(ShareAccountApiConstants.additionalcollectionids_paramname, element);
if (array != null) {
for (final JsonElement arrayElement : array) {
if (arrayElement != null && !arrayElement.isJsonNull()) {
final Long id = arrayElement.getAsLong();
if (!ids.contains(id)) {
ids.add(id);
}
}
}
}
}
return ids;
}
/**
* Checks the Share Sundry receipts a cash-funded tranche names.
*
* Each receipt is checked on its own for the things that make it unusable — it does not exist,
* it belongs to somebody else, it has already been spent, or it is reserved by another pending
* tranche. That last check matters most: without it the same voucher could fund two tranches, so
* Share Sundry would be credited once and released twice.
*
* The value check is the one thing judged across all of them together, because that is how a
* member pays in instalments: ₹600 today and ₹400 tomorrow fund a ₹1,000 tranche, though neither
* receipt covers it alone.
*/
private void validateAdvanceCollectionsForTranche(final List<Long> advanceCollectionIds, final ShareAccount account,
final Long sharesRequested, final LocalDate requestedDate, final DataValidatorBuilder baseDataValidator) {
BigDecimal receiptTotal = BigDecimal.ZERO;
for (final Long advanceCollectionId : advanceCollectionIds) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT receipt_no, voucher_no, client_id, amount, status FROM m_share_advance_collection WHERE id = ?",
advanceCollectionId);
if (rows.isEmpty()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
.failWithCode("share.advance.collection.not.found", "A voucher for this payment could not be found.");
continue;
}
final Map<String, Object> collection = rows.get(0);
// Named in every message below, because with several receipts in play "the voucher is
// already used" does not say which one to go and look at.
final Object label = collection.get("voucher_no") != null ? collection.get("voucher_no") : collection.get("receipt_no");
final Number collectionClientId = (Number) collection.get("client_id");
if (collectionClientId == null || collectionClientId.longValue() != account.getClientId().longValue()) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
.failWithCode("share.advance.collection.client.mismatch",
"Voucher " + label + " belongs to a different member.");
}
// Only money still sitting in Sundry can fund a tranche. APPLIED has already become
// share capital somewhere, and REFUNDED went back to the member.
final String status = (String) collection.get("status");
if (!"UNAPPLIED".equals(status)) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
.failWithCode("share.advance.collection.not.unapplied",
"Voucher " + label + " is no longer available — it reads " + status + ".");
}
if (isReservedByAnotherTranche(advanceCollectionId)) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(advanceCollectionId)
.failWithCode("share.advance.collection.already.reserved",
"Voucher " + label + " is already reserved against another pending share request.");
}
final BigDecimal receiptAmount = (BigDecimal) collection.get("amount");
if (receiptAmount != null) {
receiptTotal = receiptTotal.add(receiptAmount);
}
}
final BigDecimal trancheValue = account.getShareProduct().deriveMarketPrice(requestedDate)
.multiply(BigDecimal.valueOf(sharesRequested));
if (receiptTotal.compareTo(trancheValue) < 0) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(receiptTotal)
.failWithCode("share.advance.collection.insufficient", "The vouchers selected come to " + receiptTotal
+ ", which does not cover the " + trancheValue + " value of the shares requested.");
}
}
/**
* Whether this receipt is already earmarked by a pending tranche.
*
* Checks the lead column and the instalment list, because a receipt reserved as somebody's
* second instalment is just as spent as one reserved as a lead.
*/
private boolean isReservedByAnotherTranche(final Long advanceCollectionId) {
final Long count = this.jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_share_account_transactions
WHERE is_active = true AND status_enum = ?
AND (advance_collection_id = ?
OR string_to_array(COALESCE(additional_collection_ids, ''), ',') @> ARRAY[CAST(? AS text)])
""", Long.class, PurchasedSharesStatusType.APPLIED.getValue(), advanceCollectionId, String.valueOf(advanceCollectionId));
return count != null && count > 0;
}
private boolean isTransactionBeforeExistingTransactions(LocalDate requestedDate, boolean isTransactionBeforeExistingTransactions,
ShareAccount shareAccount) {
Collection<ShareAccountTransaction> activeTransactions = shareAccount.getShareAccountTransactions().stream()
@@ -0,0 +1,475 @@
/**
* 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.service;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
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.portfolio.accounts.constants.ShareAccountApiConstants;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccount;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountTransaction;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Funds one additional-share tranche — moves the member's money into the Share Sundry holding
* 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.
*
* <p>
* <b>Where the money ends up.</b> Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts
* the same Dr Sundry / Cr Share Capital pair either way — that pair is Fineract's own, posted by
* {@code CashBasedAccountingProcessorForShares} when the transaction turns APPROVED. Nothing here posts it; this only
* gets the money into Sundry in the first place.
*
* <ul>
* <li><b>Cash</b> — already there. The cashier credited Sundry when the receipt was issued, so funding a cash tranche
* leaves no net journal entries: it reverses the stock APPLIED posting (which would credit Sundry a second time for
* money that came in once) and reserves the receipt against this tranche so it cannot be spent twice.</li>
* <li><b>Savings</b> — not there yet, so this debits the member:
*
* <pre>
* Dr Savings Control (tranche value)
* Cr Share Sundry (tranche value)
* </pre>
*
* The member's balance drops on submit, not on approval. The withdrawal goes through Fineract's own savings withdrawal
* command so it reaches the member's passbook and statement exactly as a counter withdrawal would; that command posts
* its own Dr Savings Control / Cr Cash pair, which is wrong here because no cash left the branch, so those entries are
* reversed and replaced with the pair above.</li>
* </ul>
*
* <p>
* Note that Fineract's stock accounting <i>also</i> posts an APPLIED leg of its own for a new purchase
* (Dr Shares Reference / Cr Shares Suspense), assuming cash is being received at the counter right now. Neither route
* here works that way, so both reverse that pair in full: on the cash route the cashier already posted exactly those
* entries when the voucher was issued, and on the savings route the money came from the member's account rather than the
* till. Reversing only the reference debit and keeping the stock Sundry credit would leave Sundry credited twice — once
* by the stock leg and once by the pair posted here — so the whole pair goes, and Sundry ends up credited exactly once.
*/
@Component
@RequiredArgsConstructor
public class AdditionalShareTrancheFundingService {
/** 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. */
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
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 SAVINGS_STATUS_ACTIVE = 300;
/** m_payment_type.value seeded by changelog 0483, shared with the account-level settlement. */
private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
/**
* Funds a freshly raised tranche according to the route recorded on it.
*
* Called after the tranche has been saved and flushed, so it has an id to record against, and
* within the apply command's transaction — a tranche that cannot be funded must not exist.
*/
public void fund(final ShareAccount account, final ShareAccountTransaction transaction) {
final String fundingSource = transaction.getFundingSource();
if (fundingSource == null) {
return;
}
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
reserveAdvanceCollection(account, transaction);
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
debitSavingsIntoSundry(account, transaction);
}
}
/**
* Settles a tranche's funding once the tranche has been approved and its value released from
* Share Sundry into share capital.
*
* No journal entries of its own — Fineract's APPROVED posting already moved the money. This only
* closes the funding record, so a voucher whose money is now share capital cannot be presented
* again: until this point the receipt was merely reserved, because approval was still refusable.
*/
public void settle(final ShareAccount account, final ShareAccountTransaction transaction) {
if (!ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(transaction.getFundingSource())) {
return;
}
// Every receipt behind the tranche, not just the lead one: a member's later instalments have
// become share capital too, and one left UNAPPLIED could be spent a second time.
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
this.jdbcTemplate.update("""
UPDATE m_share_advance_collection
SET status = 'APPLIED', applied_share_account_id = ?, applied_on_date = ?
WHERE id = ? AND status = 'UNAPPLIED'
""", account.getId(), java.sql.Date.valueOf(DateUtils.getBusinessLocalDate()), collectionId);
}
}
/**
* Unwinds the funding of a tranche that was rejected, so the member's money does not sit in
* Share Sundry against shares that will never be issued.
*
* Fineract's own REJECTED posting already takes the value back out of Sundry — but it credits
* Shares Reference, the till, which is only correct for the cash route. The routes part company
* here:
*
* <ul>
* <li><b>Cash</b> — nothing to do. The money genuinely came in as cash, so returning it to the
* reference account is right, and releasing the receipt's reservation lets the clerk spend the
* voucher on another tranche or refund it at the counter.</li>
* <li><b>Savings</b> — the reference credit is wrong: no cash is going back over the counter.
* It is reversed and the member's savings are credited instead, which is where the money came
* from on submit.</li>
* </ul>
*/
public void unwind(final ShareAccount account, final ShareAccountTransaction transaction) {
final String fundingSource = transaction.getFundingSource();
if (fundingSource == null) {
return;
}
if (ShareAccountApiConstants.FUNDING_SOURCE_CASH.equals(fundingSource)) {
releaseAdvanceCollection(transaction);
} else if (ShareAccountApiConstants.FUNDING_SOURCE_SAVINGS.equals(fundingSource)) {
refundSavings(account, transaction);
}
}
/**
* Lets the voucher be used again. The receipt itself was never marked APPLIED — approval is
* what would have done that — so only the account it was earmarked against is cleared.
*/
private void releaseAdvanceCollection(final ShareAccountTransaction transaction) {
for (final Long collectionId : transaction.getAllAdvanceCollectionIds()) {
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = NULL WHERE id = ?", collectionId);
}
}
/**
* Returns a rejected tranche's money to the savings account it was taken from.
*/
private void refundSavings(final ShareAccount account, final ShareAccountTransaction transaction) {
if (account.getSavingsAccount() == null) {
return;
}
final BigDecimal amount = transaction.amount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
final Long savingsAccountId = account.getSavingsAccount().getId();
final Long productId = account.getShareProduct().getId();
final Long officeId = account.getOfficeId();
final String currencyCode = account.getCurrency().getCode();
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.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 = "Additional shares rejected, refunded to savings | share account " + account.getAccountNumber();
// Fineract's own deposit command, so the refund reaches the member's passbook and statement
// as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in
// over the counter — so the cash debit is replaced by a Sundry debit below.
final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note);
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
// Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the
// till, so that credit is reversed; the Sundry debit it raised alongside is correct and
// stands.
reverseStockReferenceCredit(transaction.getId(), productId, officeId);
final AppUser currentUser = this.context.authenticatedUser();
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHTRREF-" + transaction.getId();
insertEntry(officeId, glSavingsControl, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
"Rejected additional shares refunded to savings " + account.getSavingsAccountNo() + " | " + account.getAccountNumber());
}
/**
* Reverses the Shares Reference credit that Fineract's stock REJECTED posting raised for this
* tranche, leaving its Sundry debit standing.
*/
private void reverseStockReferenceCredit(final Long shareTransactionId, final Long productId, final Long officeId) {
final Long glReference = requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference");
reverseTrancheEntries(shareTransactionId, officeId, glReference, CREDIT);
}
/**
* Reverses this tranche's own unreversed entries on one GL account and one side.
*
* Matched on {@code share_transaction_id} rather than the {@code SH<id>} transaction_id string,
* because the APPLIED, APPROVED and REJECTED postings all reuse that same string; narrowing to a
* single GL and side is what keeps each reversal to the posting it is meant to undo.
*/
private void reverseTrancheEntries(final Long shareTransactionId, final Long officeId, final Long glAccountId, final int typeEnum) {
this.jdbcTemplate.update("""
UPDATE acc_gl_journal_entry SET reversed = true
WHERE share_transaction_id = ? AND account_id = ? AND office_id = ? AND type_enum = ? AND reversed = false
""", shareTransactionId, glAccountId, officeId, typeEnum);
}
private Long executeSavingsDeposit(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().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()).build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId();
}
/**
* Cash route: the money is already in Sundry, so this only ties the receipt to this tranche.
*
* The receipt is left UNAPPLIED. It is not share capital yet — approval is what releases it —
* and marking it APPLIED now would say the money had already become capital. The tranche's own
* {@code advance_collection_id} is what stops it funding a second tranche, checked when the
* next request is validated.
*/
private void reserveAdvanceCollection(final ShareAccount account, final ShareAccountTransaction transaction) {
final List<Long> collectionIds = transaction.getAllAdvanceCollectionIds();
if (collectionIds.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.cash.without.voucher",
"A cash-funded share request must name the voucher the money was received on.");
}
// Fineract's stock APPLIED posting raises Dr Cash at Vault / Cr Share Sundry for a new
// purchase. On this route the cashier already posted exactly that pair when the voucher was
// issued, so leaving it would count the member's money twice — Sundry credited 200 against a
// 100 receipt, and the till debited twice for cash that came in once. Both legs are reversed:
// submitting a cash-funded tranche moves no money, it only earmarks the receipt.
reverseStockAppliedPair(transaction.getId(), account.getShareProduct().getId(), account.getOfficeId());
for (final Long collectionId : collectionIds) {
this.jdbcTemplate.update("UPDATE m_share_advance_collection SET applied_share_account_id = ? WHERE id = ?", account.getId(),
collectionId);
}
}
/**
* Savings route: debits the member and credits Share Sundry.
*/
private void debitSavingsIntoSundry(final ShareAccount account, final ShareAccountTransaction transaction) {
if (account.getSavingsAccount() == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.no.savings",
"This share account has no linked savings account to debit.");
}
final Long savingsAccountId = account.getSavingsAccount().getId();
final BigDecimal amount = transaction.amount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.amount.invalid",
"The value of the shares requested could not be determined.");
}
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.not.active",
"Savings account " + savings.get("account_no") + " is not active.");
}
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
if (balance == null || balance.compareTo(amount) < 0) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.savings.insufficient",
"Savings account " + savings.get("account_no") + " does not hold enough to cover this share request.");
}
final Long productId = account.getShareProduct().getId();
final Long officeId = account.getOfficeId();
final String currencyCode = account.getCurrency().getCode();
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.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 = "Additional shares funded from savings | share account " + account.getAccountNumber();
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
// pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch.
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note);
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
// Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry,
// assuming cash over the counter. BOTH legs are reversed, not just the reference debit:
// leaving its Sundry credit standing and then posting the savings pair below would credit
// Sundry twice — 200 held against a 100 debit — and the surplus would never clear.
reverseStockAppliedPair(transaction.getId(), productId, officeId);
final AppUser currentUser = this.context.authenticatedUser();
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHTRSAV-" + transaction.getId();
insertEntry(officeId, glSavingsControl, DEBIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
"Additional shares debited from savings " + savings.get("account_no") + " | " + account.getAccountNumber());
insertEntry(officeId, glSundry, CREDIT, amount, txnId, entryDate, account.getId(), userId, now, currencyCode,
"Additional shares held in Share Sundry pending approval | " + account.getAccountNumber());
transaction.updateSavingsDebitRequestId(savingsTxnId);
}
/**
* Reverses the Shares Reference debit that Fineract's stock APPLIED posting raised for this
* tranche, leaving its Sundry credit standing.
*
* Scoped to this tranche's own entries and to those two GL accounts and sides specifically, so
* the Sundry-to-Share-Capital pair raised later at approval is never caught by it.
*/
private void reverseStockAppliedPair(final Long shareTransactionId, final Long productId, final Long officeId) {
reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_REFERENCE, "Shares Reference"), DEBIT);
reverseTrancheEntries(shareTransactionId, officeId, requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"),
CREDIT);
}
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();
}
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.share.tranche.no.payment.type",
"No payment types are configured. Add one before funding shares from savings.");
}
return toLong(fallback.get(0).get("id"));
}
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
return this.jdbcTemplate.queryForMap(
"SELECT id, account_no, status_enum, account_balance_derived, product_id FROM m_savings_account WHERE id = ?",
savingsAccountId);
}
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"));
}
private Long requireShareGl(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.share.tranche.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
return glId;
} catch (final IncorrectResultSizeDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.share.tranche.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. The instant has to be resolved in the tenant's own
* zone first, which is the zone {@code now} was taken in.
*/
private OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime().withOffsetSameInstant(ZoneOffset.UTC);
}
private static Long toLong(final Object value) {
return value == null ? null : ((Number) value).longValue();
}
private static int toInt(final Object value) {
return value == null ? 0 : ((Number) value).intValue();
}
}
@@ -50,7 +50,9 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
StringBuilder buff = new StringBuilder()
.append("saps.id, saps.account_id, saps.transaction_date, saps.total_shares, saps.unit_price, ")
.append("saps.status_enum, saps.type_enum, saps.amount, saps.charge_amount as chargeamount, ")
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate")
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate, ")
.append("saps.funding_source as fundingSource, saps.advance_collection_id as advanceCollectionId, ")
.append("saps.additional_collection_ids as additionalCollectionIds, saps.savings_debit_request_id as savingsDebitRequestId")
.append(" from m_share_account_transactions saps ");
schema = buff.toString();
}
@@ -72,9 +74,14 @@ public class PurchasedSharesReadPlatformServiceImpl implements PurchasedSharesRe
final String resolutionNumber = rs.getString("resolutionNumber");
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
final String fundingSource = rs.getString("fundingSource");
final Long advanceCollectionId = JdbcSupport.getLong(rs, "advanceCollectionId");
final String additionalCollectionIds = rs.getString("additionalCollectionIds");
final Long savingsDebitRequestId = JdbcSupport.getLong(rs, "savingsDebitRequestId");
return new ShareAccountTransactionData(id, accountId, purchasedDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate, fundingSource, advanceCollectionId,
additionalCollectionIds, savingsDebitRequestId);
}
public String schema() {
@@ -471,7 +471,9 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
StringBuilder buff = new StringBuilder().append(
"saps.id as purchasedId, saps.account_id as accountId, saps.transaction_date as transactionDate, saps.total_shares as purchasedShares, saps.unit_price as unitPrice, ")
.append("saps.status_enum as purchaseStatus, saps.type_enum as purchaseType, saps.amount as amount, saps.charge_amount as chargeamount, ")
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate ");
.append("saps.amount_paid as amountPaid, saps.resolution_number as resolutionNumber, saps.meeting_date as meetingDate, ")
.append("saps.funding_source as fundingSource, saps.advance_collection_id as advanceCollectionId, ")
.append("saps.additional_collection_ids as additionalCollectionIds, saps.savings_debit_request_id as savingsDebitRequestId ");
schema = buff.toString();
}
@@ -493,8 +495,13 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
final String resolutionNumber = rs.getString("resolutionNumber");
final java.sql.Date meetingDateSql = rs.getDate("meetingDate");
final LocalDate meetingDate = meetingDateSql != null ? meetingDateSql.toLocalDate() : null;
final String fundingSource = rs.getString("fundingSource");
final Long advanceCollectionId = JdbcSupport.getLong(rs, "advanceCollectionId");
final String additionalCollectionIds = rs.getString("additionalCollectionIds");
final Long savingsDebitRequestId = JdbcSupport.getLong(rs, "savingsDebitRequestId");
return new ShareAccountTransactionData(id, accountId, transactionDate, numberOfShares, purchasedPrice, statusEnum, typeEnum,
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate);
amount, chargeAmount, amountPaid, resolutionNumber, meetingDate, fundingSource, advanceCollectionId,
additionalCollectionIds, savingsDebitRequestId);
}
public String schema() {
@@ -74,6 +74,8 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
private final NoteRepository noteRepository;
/** Moves an additional-share tranche's money into Share Sundry when the tranche is raised. */
private final AdditionalShareTrancheFundingService trancheFundingService;
private final BusinessEventNotifierService businessEventNotifierService;
@@ -216,6 +218,9 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
Set<ShareAccountTransaction> transactions = new HashSet<>();
transactions.add(transaction);
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
// After the stock APPLIED posting, not before: the savings route reverses that
// posting's Shares Reference debit, which has to exist to be reversed.
this.trancheFundingService.fund(account, transaction);
}
}
@@ -402,6 +407,11 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
totalSubscribedShares += transaction.getTotalShares();
}
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
// The APPROVED posting above has released the money from Share Sundry into
// share capital, so the funding behind it can now be closed off.
for (ShareAccountTransaction transaction : transactions) {
this.trancheFundingService.settle(account, transaction);
}
}
if (!totalSubscribedShares.equals(Long.valueOf(0))) {
ShareProduct shareProduct = account.getShareProduct();
@@ -436,6 +446,11 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
transactions.add(transaction);
}
this.journalEntryWritePlatformService.createJournalEntriesForShares(populateJournalEntries(account, transactions));
// After the stock REJECTED posting, whose Shares Reference credit the savings
// route reverses in favour of refunding the member.
for (ShareAccountTransaction transaction : transactions) {
this.trancheFundingService.unwind(account, transaction);
}
}
}
return new CommandProcessingResultBuilder() //
@@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountDividendRe
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountPendingEditRepository;
import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountRepositoryWrapper;
import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDataSerializer;
import org.apache.fineract.portfolio.shareaccounts.service.AdditionalShareTrancheFundingService;
import org.apache.fineract.portfolio.shareaccounts.service.PurchasedSharesReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.service.PurchasedSharesReadPlatformServiceImpl;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountChargeReadPlatformService;
@@ -125,10 +126,10 @@ public class ShareAccountsConfiguration {
ShareAccountRepositoryWrapper shareAccountRepository, ShareProductRepositoryWrapper shareProductRepository,
AccountNumberGenerator accountNumberGenerator, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
JournalEntryWritePlatformService journalEntryWritePlatformService, NoteRepository noteRepository,
BusinessEventNotifierService businessEventNotifierService) {
AdditionalShareTrancheFundingService trancheFundingService, BusinessEventNotifierService businessEventNotifierService) {
return new ShareAccountWritePlatformServiceJpaRepositoryImpl(accountDataSerializer, shareAccountRepository, shareProductRepository,
accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository,
businessEventNotifierService);
trancheFundingService, businessEventNotifierService);
}
@Bean
@@ -567,4 +567,7 @@
<include file="parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml" relativeToChangelogFile="true"/>
<include file="parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0478_add_fd_payment_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0491_add_funding_source_to_share_transaction.xml" relativeToChangelogFile="true"/>
<include file="parts/0492_add_additional_collection_ids_to_share_transaction.xml" relativeToChangelogFile="true"/>
<include file="parts/0493_enable_maker_checker_for_share_account_create.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,65 @@
<?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">
<!--
How an additional-share tranche was paid for.
A tranche's money sits in the Share Sundry (SHARES_SUSPENSE) holding account until the
tranche is approved, at which point it is released into share capital. That release tells
you nothing about where the money came in from, and the two routes are reversed very
differently if the tranche is rejected: a cash voucher goes back to unapplied and is spendable
again, whereas a savings debit has to be refunded to the member. So the route is recorded on
the tranche itself rather than inferred later.
Nullable throughout, and deliberately not backfilled: tranches raised before this was
captured genuinely have no recorded route, and stamping a guessed one on them would
misrepresent how they were funded.
-->
<changeSet author="fineract" id="0491-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_share_account_transactions" columnName="funding_source"/></not>
</preConditions>
<addColumn tableName="m_share_account_transactions">
<!-- CASH or SAVINGS. -->
<column name="funding_source" type="VARCHAR(20)"/>
<!-- The m_share_advance_collection receipt funding a CASH tranche. Held here so one
voucher cannot be spent against two tranches, and so the approver can see which
receipt the money being released came in on. -->
<column name="advance_collection_id" type="BIGINT"/>
<!-- The m_share_savings_debit_request behind a SAVINGS tranche, for the same reason. -->
<column name="savings_debit_request_id" type="BIGINT"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0491-2">
<preConditions onFail="MARK_RAN">
<not><indexExists tableName="m_share_account_transactions" indexName="idx_msat_advance_collection_id"/></not>
</preConditions>
<!-- Checking whether a voucher has already funded a tranche happens on every cash-route
submission, so it is looked up by this column rather than scanned for. -->
<createIndex tableName="m_share_account_transactions" indexName="idx_msat_advance_collection_id">
<column name="advance_collection_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?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">
<!--
The rest of the vouchers funding one additional-share tranche.
A member often pays a tranche in instalments — ₹600 today, the ₹400 balance tomorrow — and
neither receipt covers it alone. The lead receipt stays in advance_collection_id so existing
single-voucher rows and the queries over them are unaffected; this column carries the others
as a comma-separated list of ids.
A list in a column rather than a child table: these ids are only ever read and written whole,
with the tranche, and are never searched or joined on their own. A child table would add a
join to every read of a tranche for no query that needs it.
-->
<changeSet author="fineract" id="0492-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_share_account_transactions" columnName="additional_collection_ids"/></not>
</preConditions>
<addColumn tableName="m_share_account_transactions">
<column name="additional_collection_ids" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Send a newly created share account for approval instead of committing it outright.
A share account records a member subscribing to share capital, so it belongs under the same
second pair of eyes as opening a savings account. Until now CREATE_SHAREACCOUNT had
can_maker_checker = false, which is what kept a new account out of the Approvals (checker
inbox) queue: the global maker-checker setting is already on, so this per-permission flag was
the only thing standing between the two.
Note this is distinct from the share account's own Approve action. That one moves the account
from "Submitted and pending approval" to "Approved" in its own lifecycle, and is a decision
about the subscription. Maker-checker here is a decision about the data entry — whether the
account should have been created at all, and with these details — and it happens first.
-->
<changeSet id="0493_add_share_account_create_checker_permission" author="CBS" runOnChange="false">
<!--
The checker half of the pair. Without a permission of this exact name no role can be
granted the right to approve a queued share account, so every queued account would be
unapprovable by anyone but a super user. Inserted with can_maker_checker = false, matching
CREATE_SAVINGSACCOUNT_CHECKER and CREATE_CLIENT_CHECKER: a checker row is the permission
to approve, never itself a thing that queues for approval.
-->
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'SHAREACCOUNT', 'CREATE_SHAREACCOUNT_CHECKER', 'SHAREACCOUNT', 'CREATE_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_SHAREACCOUNT_CHECKER');
</sql>
</changeSet>
<changeSet id="0493_enable_maker_checker_for_share_account_create" author="CBS" runOnChange="false">
<!--
Only CREATE_SHAREACCOUNT is switched on, not UPDATE_SHAREACCOUNT, even though both share
the CREATE action_name. Share account edits already have their own review flow
(SUBMITEDIT / APPROVEEDIT, which shows a reviewer just what changed), and routing them
through the checker inbox as well would put one edit in two queues.
-->
<sql>
UPDATE m_permission SET can_maker_checker = true WHERE code = 'CREATE_SHAREACCOUNT';
</sql>
</changeSet>
<changeSet id="0493_grant_share_account_create_checker_permission" author="CBS" runOnChange="false">
<!--
Granted to whichever roles already hold CREATE_SAVINGSACCOUNT_CHECKER, rather than to a
hard-coded list of role ids: approving a new share account is the same job as approving a
new savings account, so it belongs with the same people, and role ids differ per tenant.
Without this the switch above would queue every new share account for an approval nobody
holds the permission to give.
-->
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE p.code = 'CREATE_SHAREACCOUNT_CHECKER'
AND r.id IN (SELECT rp.role_id FROM m_role_permission rp
JOIN m_permission sp ON sp.id = rp.permission_id
WHERE sp.code = 'CREATE_SAVINGSACCOUNT_CHECKER')
AND NOT EXISTS (SELECT 1 FROM m_role_permission x
WHERE x.role_id = r.id AND x.permission_id = p.id);
</sql>
</changeSet>
</databaseChangeLog>