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 deleted file mode 100644 index 0cc431725..000000000 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareSavingsDebitRequestApiResource.java +++ /dev/null @@ -1,629 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.fineract.portfolio.cashtransfer.api; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DefaultValue; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.apache.fineract.commands.domain.CommandWrapper; -import org.apache.fineract.commands.service.CommandWrapperBuilder; -import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; -import org.apache.fineract.useradministration.domain.AppUser; -import org.springframework.dao.EmptyResultDataAccessException; -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * Settles a share application out of the member's own savings account. - * - * The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks - * in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks - * with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly: - * - *
- *   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)
- * 
- * - * Both steps are posted together on approval, so Sundry nets to zero for the settlement and holds - * nothing afterwards. It is still routed through rather than around: every other way money reaches - * share capital — the cashier's advance collection, and both additional-share routes — passes through - * Sundry, and that balance is what the share ledger reconciles against. - * - * Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval - * touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair - * of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves. - * - * The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and - * running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own - * Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and - * replaced with the share-capital pair above. - */ -@Path("/v1/sharesavingsdebit") -@Component -@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.") -@RequiredArgsConstructor -@Consumes({ MediaType.APPLICATION_JSON }) -@Produces({ MediaType.APPLICATION_JSON }) -public class ShareSavingsDebitRequestApiResource { - - // Fineract convention: 1 = CREDIT, 2 = DEBIT. - private static final int CREDIT = 1; - private static final int DEBIT = 2; - - // acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource. - /** Share Sundry — the holding account the member's money passes through on its way to capital. */ - private static final int FA_SHARES_SUSPENSE = 2; - private static final int FA_INCOME_FROM_FEES = 3; - private static final int FA_SHARES_EQUITY = 4; - private static final int PRODUCT_TYPE_SHARES = 4; - - /** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */ - private static final int PRODUCT_TYPE_SAVINGS = 2; - private static final int FA_SAVINGS_CONTROL = 2; - - private static final int SHARE_STATUS_ACTIVE = 300; - private static final int SAVINGS_STATUS_ACTIVE = 300; - - /** m_payment_type.value seeded by changelog 0483 for this settlement. */ - private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement"; - - private final PlatformSecurityContext context; - private final JdbcTemplate jdbcTemplate; - private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; - private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create(); - - // ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ────────────── - - /** - * Whether this share account can be settled from savings, and for how much. - * - * Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked, - * does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the - * counter instead of after a request has been raised and has to be rejected. - */ - @GET - @Path("/eligibility/{shareAccountId}") - public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) { - this.context.authenticatedUser(); - final Map acct = loadShareAccount(shareAccountId); - - final BigDecimal shareAmount = shareValueOf(shareAccountId); - final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); - final BigDecimal required = shareAmount.add(chargeAmount); - - final Map out = new LinkedHashMap<>(); - out.put("shareAccountId", shareAccountId); - out.put("shareAccountNo", acct.get("account_no")); - out.put("shareAmount", shareAmount); - out.put("chargeAmount", chargeAmount); - out.put("totalRequired", required); - - final Object savingsAccountIdRaw = acct.get("savings_account_id"); - if (savingsAccountIdRaw == null) { - out.put("eligible", false); - out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead."); - return Response.ok(gson.toJson(out)).build(); - } - final Long savingsAccountId = toLong(savingsAccountIdRaw); - final Map savings = loadSavingsAccount(savingsAccountId); - final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); - final BigDecimal available = balance == null ? BigDecimal.ZERO : balance; - - out.put("savingsAccountId", savingsAccountId); - out.put("savingsAccountNo", savings.get("account_no")); - out.put("savingsBalance", available); - - final String blocker = settlementBlocker(acct, savings, required, available); - out.put("eligible", blocker == null); - out.put("reason", blocker); - return Response.ok(gson.toJson(out)).build(); - } - - /** - * Why this share account cannot be settled from savings right now, or null when it can. - * - * Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement - * is allowed. - */ - private String settlementBlocker(final Map shareAccount, final Map savings, - final BigDecimal required, final BigDecimal available) { - - if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) { - return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled."; - } - if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) { - return "Share account " + shareAccount.get("account_no") + " has already been settled."; - } - if (required.compareTo(BigDecimal.ZERO) <= 0) { - return "There is nothing outstanding on share account " + shareAccount.get("account_no") + "."; - } - // The savings account must belong to the same member — debiting anyone else's balance for - // these shares would be taking one member's money for another's holding. - if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) { - return "The linked savings account belongs to a different member."; - } - if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) { - return "Savings account " + savings.get("account_no") + " is not Active."; - } - if (available.compareTo(required) < 0) { - return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required - + " is required. Take a deposit first, or settle by cash collection."; - } - return null; - } - - // ── POST /v1/sharesavingsdebit ── Clerk records the request ───────────── - - @POST - @Transactional - public Response submit(final String body) { - final AppUser currentUser = this.context.authenticatedUser(); - final JsonObject json = parseBody(body); - final Long shareAccountId = getLong(json, "shareAccountId"); - if (shareAccountId == null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required."); - } - - final Map acct = loadShareAccount(shareAccountId); - final Object savingsAccountIdRaw = acct.get("savings_account_id"); - if (savingsAccountIdRaw == null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings", - "Share account " + acct.get("account_no") + " has no linked savings account."); - } - final Long savingsAccountId = toLong(savingsAccountIdRaw); - final Map savings = loadSavingsAccount(savingsAccountId); - - final BigDecimal shareAmount = shareValueOf(shareAccountId); - final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); - final BigDecimal required = shareAmount.add(chargeAmount); - final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); - - // Checked at submit as well as at approval: the clerk should be told now, not after the - // member has left and a manager finds the account short. - final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance); - if (blocker != null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker); - } - - final Long existingPending = this.jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class, - shareAccountId); - if (existingPending != null && existingPending > 0) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending", - "A savings-debit request for this share account is already pending review."); - } - - final Long newId = this.jdbcTemplate.queryForObject(""" - INSERT INTO m_share_savings_debit_request - (share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount, - currency_code, status, clerk_id, clerk_name, note) - VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?) - RETURNING id - """, Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required, - acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note")); - - final Map out = new LinkedHashMap<>(); - out.put("resourceId", newId); - out.put("status", "PENDING"); - out.put("totalAmount", required); - return Response.ok(gson.toJson(out)).build(); - } - - // ── GET /v1/sharesavingsdebit ── Requests awaiting review ─────────────── - - @GET - public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status, - @QueryParam("shareAccountId") final Long shareAccountId) { - this.context.authenticatedUser(); - - final StringBuilder sql = new StringBuilder(""" - SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name, - r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance, - r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status, - r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at, - r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note - FROM m_share_savings_debit_request r - JOIN m_share_account sa ON sa.id = r.share_account_id - JOIN m_client c ON c.id = r.client_id - LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id - WHERE 1 = 1 - """); - final List params = new ArrayList<>(); - if (!"ALL".equalsIgnoreCase(status)) { - sql.append(" AND r.status = ? "); - params.add(status.toUpperCase()); - } - if (shareAccountId != null) { - sql.append(" AND r.share_account_id = ? "); - params.add(shareAccountId); - } - sql.append(" ORDER BY r.submitted_at DESC, r.id DESC"); - - return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build(); - } - - // ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ───────── - - /** - * Debits the member's savings and issues the shares. - * - * Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the - * approval is the point at which it actually matters. - */ - @POST - @Path("/{id}/approve") - @Transactional - public Response approve(@PathParam("id") final Long id, final String body) { - final AppUser currentUser = this.context.authenticatedUser(); - currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY"); - validateCheckerRole(currentUser); - - final Map req = loadRequest(id); - if (!"PENDING".equals(req.get("status"))) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending", - "Only PENDING requests can be approved. This one is " + req.get("status") + "."); - } - if (toLong(req.get("clerk_id")).equals(currentUser.getId())) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval", - "Maker-checker: you cannot approve a request you submitted yourself. Ask another manager."); - } - - final Long shareAccountId = toLong(req.get("share_account_id")); - final Long savingsAccountId = toLong(req.get("savings_account_id")); - final Map acct = loadShareAccount(shareAccountId); - final Map savings = loadSavingsAccount(savingsAccountId); - - // Recomputed rather than taken from the request: shares or charges may have been edited - // between submit and approval, and the member must be debited for what is actually owed. - final BigDecimal shareAmount = shareValueOf(shareAccountId); - final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); - final BigDecimal required = shareAmount.add(chargeAmount); - final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived"); - - final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance); - if (blocker != null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker); - } - - final Long productId = toLong(acct.get("product_id")); - final Long officeId = toLong(acct.get("office_id")); - final String currencyCode = (String) acct.get("currency_code"); - final String accountNo = (String) acct.get("account_no"); - - final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)"); - final Long glSavingsControl = savingsControlGlOf(savingsAccountId); - if (glSavingsControl == null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped", - "The Savings Control GL is not mapped for this savings account's product."); - } - - final LocalDate entryDate = DateUtils.getBusinessLocalDate(); - final String note = "Share application settled from savings | " + accountNo; - - // Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL - // pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money - // became share capital, which the pair below records. - final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note); - this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId); - - final long userId = currentUser.getId(); - final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()); - final String txnId = "SHSAV-" + savingsTxnId; - - // Routed through Share Sundry rather than straight from savings into share capital, in two - // steps posted together: - // - // 1. Dr Savings Control / Cr Share Sundry — the money leaves the member - // 2. Dr Share Sundry / Cr Share Capital (+ Cr Fee Income) — and is issued as shares - // - // Every other way money reaches share capital passes through Sundry — the cashier's advance - // collection and both additional-share routes — so the Sundry balance is what the whole - // share ledger reconciles against. A settlement that jumped the queue straight to capital - // left that reconciliation permanently short by its own amount, and left no single account - // where money taken for shares but not yet issued could be seen. - final Long glSundry = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)"); - - insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share application debited from savings " + savings.get("account_no") + " | " + accountNo); - insertEntry(officeId, glSundry, CREDIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share application held in Share Sundry | " + accountNo); - - // The full amount comes back out of Sundry — share value to capital, entry fee to income — - // so Sundry nets to zero for this settlement rather than retaining the fee. - insertEntry(officeId, glSundry, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share Sundry released for " + accountNo); - insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share capital issued for " + accountNo); - - if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { - final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); - insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, - "Share entry fee for " + accountNo); - this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " - + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); - } - - // Marks the account settled, which is what stops it being settled again by either route. - this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); - - this.jdbcTemplate.update(""" - UPDATE m_share_savings_debit_request - SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(), - share_amount = ?, charge_amount = ?, total_amount = ?, - savings_transaction_id = ?, gl_transaction_id = ? - WHERE id = ? AND status = 'PENDING' - """, currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id); - - final Map out = new LinkedHashMap<>(); - out.put("resourceId", id); - out.put("status", "APPROVED"); - out.put("shareAmount", shareAmount); - out.put("chargeAmount", chargeAmount); - out.put("totalDebited", required); - out.put("savingsTransactionId", savingsTxnId); - return Response.ok(gson.toJson(out)).build(); - } - - // ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ─────────── - - @POST - @Path("/{id}/reject") - @Transactional - public Response reject(@PathParam("id") final Long id, final String body) { - final AppUser currentUser = this.context.authenticatedUser(); - validateCheckerRole(currentUser); - - final Map req = loadRequest(id); - if (!"PENDING".equals(req.get("status"))) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending", - "Only PENDING requests can be rejected. This one is " + req.get("status") + "."); - } - - final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body); - this.jdbcTemplate.update(""" - UPDATE m_share_savings_debit_request - SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ? - WHERE id = ? AND status = 'PENDING' - """, currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id); - - final Map out = new LinkedHashMap<>(); - out.put("resourceId", id); - out.put("status", "REJECTED"); - return Response.ok(gson.toJson(out)).build(); - } - - // ── Helpers ──────────────────────────────────────────────────────────── - - private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, - final String note) { - final JsonObject payload = new JsonObject(); - payload.addProperty("locale", "en"); - payload.addProperty("dateFormat", "yyyy-MM-dd"); - payload.addProperty("transactionDate", txnDate.toString()); - payload.addProperty("transactionAmount", amount); - payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId()); - payload.addProperty("note", note); - - final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString()) - .build(); - final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd); - return result.getResourceId(); - } - - /** - * The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the - * withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out. - * The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it - * on the member's statement and in payment-type reporting. - * - * Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles - * rather than failing at approval with money already half-moved. - */ - private Long resolveSettlementPaymentTypeId() { - final List> 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.sharesavingsdebit.no.payment.type", - "No payment types are configured. Add one before settling a share application from savings."); - } - return toLong(fallback.get(0).get("id")); - } - - /** Savings Control GL for this savings account's product. */ - private Long savingsControlGlOf(final Long savingsAccountId) { - final List> rows = this.jdbcTemplate.queryForList(""" - SELECT apm.gl_account_id FROM m_savings_account sa - JOIN acc_product_mapping apm ON apm.product_id = sa.product_id - WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1 - """, savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL); - return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id")); - } - - /** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */ - private BigDecimal shareValueOf(final Long shareAccountId) { - final BigDecimal total = this.jdbcTemplate - .queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions " - + " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId); - return total == null ? BigDecimal.ZERO : total; - } - - /** Entry fee still outstanding, zero when no charge was attached. */ - private BigDecimal outstandingChargeOf(final Long shareAccountId) { - final BigDecimal total = this.jdbcTemplate.queryForObject( - "SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge " - + " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0", - BigDecimal.class, shareAccountId); - return total == null ? BigDecimal.ZERO : total; - } - - private Map loadShareAccount(final Long shareAccountId) { - try { - return this.jdbcTemplate.queryForMap(""" - SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code, - sa.collection_applied, sa.savings_account_id, c.office_id - FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ? - """, shareAccountId); - } catch (final EmptyResultDataAccessException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found", - "Share account " + shareAccountId + " does not exist."); - } - } - - private Map loadSavingsAccount(final Long savingsAccountId) { - try { - return this.jdbcTemplate.queryForMap( - "SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", - savingsAccountId); - } catch (final EmptyResultDataAccessException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found", - "Savings account " + savingsAccountId + " does not exist."); - } - } - - private Map loadRequest(final Long id) { - try { - return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id); - } catch (final EmptyResultDataAccessException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found", - "Savings-debit request " + id + " does not exist."); - } - } - - private Long requireGl(final Long productId, final int financialAccountType, final String label) { - try { - final Long glId = this.jdbcTemplate.queryForObject( - "SELECT gl_account_id FROM acc_product_mapping " - + " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL", - Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType); - if (glId == null) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped", - label + " GL is not mapped for share product " + productId + "."); - } - return glId; - } catch (final IncorrectResultSizeDataAccessException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped", - label + " GL is not mapped for share product " + productId + "."); - } - } - - private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, - final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode, - final String description) { - this.jdbcTemplate.update( - "INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, " - + " entry_date, type_enum, amount, description, entity_id, " - + " created_by, last_modified_by, created_date, lastmodified_date, " - + " is_running_balance_calculated, office_running_balance, " - + " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) " - + "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)", - glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId, - userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now)); - } - - /** - * A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns. - * - * {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as - * 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's - * own zone first, which is the zone {@code now} was taken in. - */ - private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) { - return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime() - .withOffsetSameInstant(ZoneOffset.UTC); - } - - private void validateCheckerRole(final AppUser user) { - if (user.hasAnyPermission("ALL_FUNCTIONS")) { - return; - } - final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim()) - .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); - if (!isChecker) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required", - "Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request."); - } - } - - private JsonObject parseBody(final String body) { - if (body == null || body.isBlank()) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required."); - } - try { - return JsonParser.parseString(body).getAsJsonObject(); - } catch (final RuntimeException e) { - throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON."); - } - } - - private Long getLong(final JsonObject json, final String field) { - return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null; - } - - private String getString(final JsonObject json, final String field) { - if (!json.has(field) || json.get(field).isJsonNull()) { - return null; - } - final String value = json.get(field).getAsString().trim(); - return value.isEmpty() ? null : value; - } - - private Long toLong(final Object o) { - return o instanceof Number n ? n.longValue() : null; - } - - private int toInt(final Object o) { - return o instanceof Number n ? n.intValue() : 0; - } -} 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 index 709959d15..1b6345cc4 100644 --- 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 @@ -45,14 +45,9 @@ 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. + * Deliberately scoped to a single tranche rather than the account's whole holding history: the amount here is the + * caller's — the tranche is not saved yet, so there is no row to read it from. The share count is passed in and + * priced at the product's current market price, which is the same figure the apply form shows the member. */ @Path("/v1/shareaccounts/{accountId}/additionalshares") @Component diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java new file mode 100644 index 000000000..66d369a66 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareAdditionRequestsApiResource.java @@ -0,0 +1,196 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.shareaccounts.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Additional-share tranches awaiting approval, across every share account, for a centralized Approval Center queue. + * + *

+ * A tranche raised through "Apply Additional Shares" today is only visible for approval on its own account's page + * (the native {@code approveadditionalshares}/{@code rejectadditionalshares} commands, invoked directly from a button + * there) — there was no single place a checker could see every tranche waiting across the whole membership. This + * resource is the read side of that queue: one list of every pending tranche, and one detail view per tranche with + * everything a checker needs to decide, including the route-specific proof of funding a list row cannot show — which + * voucher(s) a cash tranche is resting on, or which savings account a savings tranche already debited. + * + *

+ * Approval and rejection themselves are NOT duplicated here — they still go through Fineract's own + * {@code approveadditionalshares}/{@code rejectadditionalshares} commands (same as the account-page buttons), so the + * journal postings and {@link org.apache.fineract.portfolio.shareaccounts.service.AdditionalShareTrancheFundingService} + * settle/unwind hooks that already run on those commands run exactly once, from exactly one code path. + */ +@Path("/v1/shareadditionrequests") +@Component +@Tag(name = "Share Addition Requests", description = "Additional-share tranches awaiting approval, across every share account.") +@RequiredArgsConstructor +@Consumes({ MediaType.APPLICATION_JSON }) +@Produces({ MediaType.APPLICATION_JSON }) +public class ShareAdditionRequestsApiResource { + + /** m_share_account_transactions.status_enum for "submitted, awaiting approval". */ + private static final int STATUS_APPLIED = 100; + /** m_share_account_transactions.type_enum for a share purchase (an original application or a tranche alike). */ + private static final int TYPE_PURCHASED = 500; + + private static final String FUNDING_CASH = "CASH"; + private static final String FUNDING_SAVINGS = "SAVINGS"; + + private final PlatformSecurityContext context; + private final JdbcTemplate jdbcTemplate; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // Every alias is double-quoted: PostgreSQL folds an unquoted alias to lower case, so + // queryForList would key the maps "shareaccountid" etc., which serialise straight into JSON and + // leave the client reading shareAccountId as undefined. + private static final String BASE_SELECT = """ + SELECT t.id AS "transactionId", + sa.id AS "shareAccountId", + sa.account_no AS "accountNo", + sa.client_id AS "clientId", + c.display_name AS "clientName", + o.name AS "officeName", + sp.name AS "productName", + sa.currency_code AS "currencyCode", + t.transaction_date AS "transactionDate", + t.total_shares AS "totalShares", + t.unit_price AS "unitPrice", + t.amount AS "amount", + t.charge_amount AS "chargeAmount", + t.resolution_number AS "resolutionNumber", + t.meeting_date AS "meetingDate", + t.funding_source AS "fundingSource", + t.advance_collection_id AS "advanceCollectionId", + t.additional_collection_ids AS "additionalCollectionIds" + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + JOIN m_client c ON c.id = sa.client_id + JOIN m_office o ON o.id = c.office_id + JOIN m_share_product sp ON sp.id = sa.product_id + """; + + @GET + @Operation(summary = "List additional-share tranches awaiting approval", + description = "Every pending tranche across every share account, with the member, account, shares and value a checker needs to see before deciding.") + public Response retrievePending() { + this.context.authenticatedUser(); + final List> rows = this.jdbcTemplate.queryForList( + BASE_SELECT + " WHERE t.is_active = true AND t.status_enum = ? AND t.type_enum = ? " + + " ORDER BY t.transaction_date, t.id", + STATUS_APPLIED, TYPE_PURCHASED); + return Response.ok(gson.toJson(rows)).build(); + } + + @GET + @Path("/{transactionId}") + @Operation(summary = "Full detail for one pending tranche", + description = "The list row plus route-specific proof of funding: the voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche.") + public Response retrieveOne(@PathParam("transactionId") final Long transactionId) { + this.context.authenticatedUser(); + + final Map row; + try { + row = this.jdbcTemplate.queryForMap(BASE_SELECT + " WHERE t.id = ?", transactionId); + } catch (final EmptyResultDataAccessException e) { + throw new PlatformDataIntegrityException("error.msg.shareaddition.not.found", + "Additional-share request " + transactionId + " does not exist."); + } + + final Map out = new LinkedHashMap<>(row); + final String fundingSource = (String) row.get("fundingSource"); + + if (FUNDING_CASH.equals(fundingSource)) { + out.put("vouchers", vouchersFor(row)); + } else if (FUNDING_SAVINGS.equals(fundingSource)) { + out.put("savingsAccount", savingsAccountFor(toLong(row.get("shareAccountId")))); + } + + return Response.ok(gson.toJson(out)).build(); + } + + /** + * Every receipt funding a cash tranche — the lead voucher plus any further instalments — so a checker sees the + * whole pot behind the amount rather than just the first receipt. + */ + private List> vouchersFor(final Map row) { + final List ids = new ArrayList<>(); + final Long lead = toLong(row.get("advanceCollectionId")); + if (lead != null) { + ids.add(lead); + } + final String additional = (String) row.get("additionalCollectionIds"); + if (additional != null && !additional.isBlank()) { + for (final String part : additional.split(",")) { + final String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + ids.add(Long.valueOf(trimmed)); + } + } + } + if (ids.isEmpty()) { + return Collections.emptyList(); + } + final String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); + return this.jdbcTemplate.queryForList(""" + SELECT id, receipt_no AS "receiptNo", voucher_no AS "voucherNo", amount, collection_date AS "collectionDate", + collected_by_name AS "collectedByName", status + FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id + """.formatted(placeholders), ids.toArray()); + } + + /** The savings account a savings-route tranche already debited, so a checker can see it was real money. */ + private Map savingsAccountFor(final Long shareAccountId) { + try { + return this.jdbcTemplate.queryForMap(""" + SELECT sav.id, sav.account_no AS "accountNo", sav.account_balance_derived AS "balance" + FROM m_savings_account sav + JOIN m_share_account sa ON sa.savings_account_id = sav.id + WHERE sa.id = ? + """, shareAccountId); + } catch (final EmptyResultDataAccessException e) { + return null; + } + } + + private Long toLong(final Object o) { + return o instanceof Number ? ((Number) o).longValue() : null; + } +} 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 81229370f..30a3a348d 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 @@ -658,17 +658,16 @@ public class ShareAccountDataSerializer { private void handlechargesOnActivation(final ShareAccount account) { // The entry fee is deliberately NOT marked paid here. Activating the account decides the - // member may hold shares; it does not collect anything, and nobody has yet said where the - // money comes from — a cash receipt at the counter, or a debit from the member's savings. + // member may hold shares; it does not collect anything, and nobody has yet said the cash + // receipt at the counter has actually been taken. // // Marking it paid at activation left amount_paid_derived at the full fee and // amount_outstanding_derived at zero while nothing had actually been received, so the // settlement step read the fee as already settled and asked the member for the share value // alone ("shares 400.00 + charges 0"). The fee was then never collected at all. // - // It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource - // (cash receipt) or ShareSavingsDebitRequestApiResource (savings debit), both of which set - // amount_paid_derived and amount_outstanding_derived alongside the flag. + // It is marked paid where the money genuinely arrives: ShareAdvanceCollectionApiResource, + // which sets amount_paid_derived and amount_outstanding_derived alongside the flag. // // The charge transaction is still dated to the activation date — that records when the fee // became due, which is a separate thing from when it was paid. 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 index 366f3bf16..d39cc5764 100644 --- 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 @@ -47,12 +47,11 @@ import org.springframework.stereotype.Component; * 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. + * Why this is separate from the account-level settlement resource. {@code ShareAdvanceCollectionApiResource} + * settles an account's first application. It values the settlement by summing every purchase row on the + * account, and guards with account-wide state — {@code collection_applied} — that assumption does not survive a + * second tranche: the valuation would charge the member for their entire holding history. So tranche funding is + * valued and recorded per transaction, and leaves that account-level state alone. * *

* Where the money ends up. Both routes leave it in Share Sundry (SHARES_SUSPENSE), so approving the tranche posts 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 22d3189e7..cde09257e 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 @@ -268,9 +268,8 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA // shares; it does not collect anything from them. Posting Dr Share Sundry / Cr Share // Capital at this point issues share capital against a Sundry balance that holds no // receipt for it — in effect funding one member's shares out of money other members - // paid in. The entries belong to settlement, where the money actually arrives: either - // applying the member's cash receipt (ShareAdvanceCollectionApiResource) or debiting - // their savings (ShareSavingsDebitRequestApiResource). + // paid in. The entries belong to settlement, where the money actually arrives: applying + // the member's cash receipt (ShareAdvanceCollectionApiResource). businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountApproveBusinessEvent(account)); return new CommandProcessingResultBuilder() // @@ -367,15 +366,14 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA // Charges are deliberately NOT marked paid here. Attaching an entry fee when the // account is created records what the member owes; activating the account decides they - // may hold shares. Neither collects anything, and nobody has yet said where the money - // is coming from — a cash receipt at the counter, or a debit from the member's savings. + // may hold shares. Neither collects anything, and nobody has yet said the cash receipt + // at the counter has actually been taken. // // Marking it paid at activation left the fee showing as settled while it was still // outstanding, so the settlement step then asked the member for the share value alone // and the fee was never collected at all. It is marked paid where the money actually - // arrives: ShareAdvanceCollectionApiResource (cash receipt) or - // ShareSavingsDebitRequestApiResource (savings debit), both of which set - // amount_paid_derived and amount_outstanding_derived alongside the flag. + // arrives: ShareAdvanceCollectionApiResource, which sets amount_paid_derived and + // amount_outstanding_derived alongside the flag. return new CommandProcessingResultBuilder() // .withCommandId(jsonCommand.commandId()) // diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index e4b93b87f..a226f501d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -46,4 +46,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_drop_share_savings_debit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_drop_share_savings_debit_request.xml new file mode 100644 index 000000000..aae3679c7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0520_drop_share_savings_debit_request.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + +