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
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:
+31
@@ -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,
|
||||
|
||||
+29
-1
@@ -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);
|
||||
|
||||
|
||||
+183
@@ -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();
|
||||
}
|
||||
}
|
||||
+107
@@ -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();
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
+106
@@ -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;
|
||||
}
|
||||
|
||||
+152
-1
@@ -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()
|
||||
|
||||
+475
@@ -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();
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -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() {
|
||||
|
||||
+9
-2
@@ -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() {
|
||||
|
||||
+15
@@ -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() //
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
+65
@@ -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>
|
||||
+45
@@ -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>
|
||||
+88
@@ -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>
|
||||
Reference in New Issue
Block a user