From 179fe3a6ce8b41f88cbeb0e69b47cef239d20b25 Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 27 Aug 2026 10:27:58 +0530 Subject: [PATCH] implement share account actions, view components, and pending checker tasks --- .../constants/ShareAccountApiConstants.java | 31 ++ .../ShareSavingsDebitRequestApiResource.java | 30 +- .../AdditionalSharesFundingApiResource.java | 183 +++++++ .../api/PendingShareAccountsApiResource.java | 107 ++++ .../data/ShareAccountTransactionData.java | 17 +- .../domain/ShareAccountTransaction.java | 106 ++++ .../ShareAccountDataSerializer.java | 153 +++++- .../AdditionalShareTrancheFundingService.java | 475 ++++++++++++++++++ ...urchasedSharesReadPlatformServiceImpl.java | 11 +- .../ShareAccountReadPlatformServiceImpl.java | 11 +- ...WritePlatformServiceJpaRepositoryImpl.java | 15 + .../start/ShareAccountsConfiguration.java | 5 +- .../db/changelog/tenant/changelog-tenant.xml | 3 + ...dd_funding_source_to_share_transaction.xml | 65 +++ ...al_collection_ids_to_share_transaction.xml | 45 ++ ...maker_checker_for_share_account_create.xml | 88 ++++ 16 files changed, 1335 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/PendingShareAccountsApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_funding_source_to_share_transaction.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_additional_collection_ids_to_share_transaction.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_enable_maker_checker_for_share_account_create.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java index c77bf5afa..265157125 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java @@ -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 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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java index f80c7fc50..0cc431725 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java @@ -65,11 +65,17 @@ import org.springframework.transaction.annotation.Transactional; * *
  *   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)
  * 
* - * 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java new file mode 100644 index 000000000..709959d15 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/AdditionalSharesFundingApiResource.java @@ -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 new additional-share tranche from their savings, and for how much. + * + *

+ * 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. + * + *

+ * 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 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 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 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 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> 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 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/PendingShareAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/PendingShareAccountsApiResource.java new file mode 100644 index 000000000..626e4c300 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/PendingShareAccountsApiResource.java @@ -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. + * + *

+ * 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> 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java index 0ce4065fa..93753320e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/data/ShareAccountTransactionData.java @@ -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; + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java index 45b314bcc..07b166044 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccountTransaction.java @@ -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 { @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 shareAccountChargesPaid = new HashSet<>(); @@ -125,6 +158,79 @@ public class ShareAccountTransaction extends AbstractPersistableCustom { 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 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 getAllAdvanceCollectionIds() { + final List 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 1e1c7dcc1..81229370f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -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 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 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 extractAdvanceCollectionIds(final JsonElement element) { + final List 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 advanceCollectionIds, final ShareAccount account, + final Long sharesRequested, final LocalDate requestedDate, final DataValidatorBuilder baseDataValidator) { + BigDecimal receiptTotal = BigDecimal.ZERO; + + for (final Long advanceCollectionId : advanceCollectionIds) { + final List> 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 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 activeTransactions = shareAccount.getShareAccountTransactions().stream() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java new file mode 100644 index 000000000..366f3bf16 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/AdditionalShareTrancheFundingService.java @@ -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. + * + *

+ * Why this is separate from the account-level settlement resources. {@code ShareAdvanceCollectionApiResource} and + * {@code ShareSavingsDebitRequestApiResource} settle an account's first 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. + * + *

+ * Where the money ends up. 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. + * + *

+ * + *

+ * Note that Fineract's stock accounting also 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: + * + *

+ */ + 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} 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 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 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> 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> 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 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> 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java index 00f5769d5..5b380801b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/PurchasedSharesReadPlatformServiceImpl.java @@ -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() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java index a77facb3e..2299b20b4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountReadPlatformServiceImpl.java @@ -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() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java index 1c7f1307f..22d3189e7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/service/ShareAccountWritePlatformServiceJpaRepositoryImpl.java @@ -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 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() // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java index 3d5aa3f2f..bc32af30f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/start/ShareAccountsConfiguration.java @@ -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 diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index d896733ae..2c023d719 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -567,4 +567,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_funding_source_to_share_transaction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_funding_source_to_share_transaction.xml new file mode 100644 index 000000000..f6ee6e4f5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0491_add_funding_source_to_share_transaction.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_additional_collection_ids_to_share_transaction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_additional_collection_ids_to_share_transaction.xml new file mode 100644 index 000000000..f8f870202 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0492_add_additional_collection_ids_to_share_transaction.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_enable_maker_checker_for_share_account_create.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_enable_maker_checker_for_share_account_create.xml new file mode 100644 index 000000000..d88a2ccbe --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0493_enable_maker_checker_for_share_account_create.xml @@ -0,0 +1,88 @@ + + + + + + + + + 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'); + + + + + + + UPDATE m_permission SET can_maker_checker = true WHERE code = 'CREATE_SHAREACCOUNT'; + + + + + + + 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); + + + +