From 8ecab08d5739084e22d6276419d39a47367a5e48 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 30 Aug 2026 20:02:50 +0530 Subject: [PATCH] fix(shares,savings): remove product-config voucher auto-resolve, fix charge/currency edit bugs, gate withdrawal on settlement - ShareAdvanceCollectionApiResource: drop the hardcoded/auto-resolved Share Suspense GL lookup for by-voucher search; GL account and voucher are now both required, manually-selected inputs. apply() now accepts a separately-verified entry-fee receipt (chargeCollectionId) so a fee collected into a different GL than the share value is never swept into the Sundry leg or double-posted to income. - ClerkShareWithdrawalRequestApiResource: hard-block approval when collection_applied is false, so shares nobody has actually paid for can't be redeemed off an Active-but-unsettled account. - ProductPendingEditFieldMapper / ShareProductDataSerializer: fix maker-checker edits on share products silently dropping charges (chargesSelected vs charges field-name mismatch) and NPEing on approval when the replay JSON carries no currencyCode. - SavingsAccount: initialize the clearing-hold field to zero so new savings submissions don't violate the NOT NULL constraint on total_savings_amount_on_hold_clearing. - CashReportApiResource: fix voucher number resolution for GL-type ledger entries. - application.properties: revert local tenant DB password default. Co-Authored-By: Claude Sonnet 5 --- .../cashreport/api/CashReportApiResource.java | 13 ++ .../ShareAdvanceCollectionApiResource.java | 199 +++++++++++++++--- ...lerkShareWithdrawalRequestApiResource.java | 24 ++- .../ShareProductDataSerializer.java | 9 +- ...WritePlatformServiceJpaRepositoryImpl.java | 6 +- .../src/main/resources/application.properties | 4 +- .../domain/ProductPendingEditFieldMapper.java | 27 ++- .../savings/domain/SavingsAccount.java | 2 +- 8 files changed, 238 insertions(+), 46 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index a277822a0..aa73ec92e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -903,10 +903,23 @@ public class CashReportApiResource { // the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/ // withdrawal request at approval time -- std.txn_id on its own is only the internal savings // transaction id (e.g. "4"), never what was actually printed on the voucher. + // + // A GL-type deposit's own txn_id is a hex string (e.g. "a29ff54dc88f", from a journal-entry + // reference) rather than a plain decimal savings-transaction id, but std.txn_id is always + // stored as the DECIMAL parse of whatever id it was given -- so for a GL row it holds the + // hex string's numeric value (178807899015311), not the hex string itself, and a plain + // string-equality join against cdr.txn_id can never match it. The second half of each OR + // below re-derives that same decimal value from the hex string to match it -- guarded to + // only run on strings that are actually hex (so an ordinary decimal txn_id, which never + // contains a-f, is never sent through the hex cast at all). final String baseGroupSql = "FROM m_savings_txn_denomination std " + "LEFT JOIN m_client mc ON mc.id = std.client_id " + "LEFT JOIN m_cashier_deposit_request cdr ON cdr.txn_id = CAST(std.txn_id AS VARCHAR) " + + " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' " + + " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) " + "LEFT JOIN m_clerk_withdrawal_request cwr ON cwr.txn_id = CAST(std.txn_id AS VARCHAR) " + + " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' " + + " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) " + "WHERE std.txn_date = CAST(? AS DATE) " + officerFilter + " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no "; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java index 8a9de15f2..e5c162e48 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/ShareAdvanceCollectionApiResource.java @@ -104,8 +104,6 @@ public class ShareAdvanceCollectionApiResource { // ── m_share_account.status_enum ─────────────────────────────────────── private static final int STATUS_ACTIVE = 300; - // ── GL account the cashier deposits share money into ────────────────── - private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001"; private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; @@ -213,17 +211,18 @@ public class ShareAdvanceCollectionApiResource { @GET @Path("/by-voucher") public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo, - @QueryParam("clientId") final Long clientIdHint) { + @QueryParam("clientId") final Long clientIdHint, @QueryParam("glAccountNo") final String glAccountNoOverride) { if (voucherNo == null || voucherNo.isBlank()) { throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required", "A voucher or receipt number is required."); } - return findByVoucher(voucherNo, clientIdHint); + return findByVoucher(voucherNo, clientIdHint, glAccountNoOverride); } @GET @Path("/by-voucher/{voucherNo}") - public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) { + public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint, + @QueryParam("glAccountNo") final String glAccountNoOverride) { this.context.authenticatedUser(); // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. @@ -234,15 +233,19 @@ public class ShareAdvanceCollectionApiResource { + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); if (rows.isEmpty()) { - // Cash is normally taken through the cashier's Transaction Entry deposit into the Share - // Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a - // collection so the member's voucher resolves here. - final Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint); + // Cash is normally taken through the cashier's Transaction Entry deposit into the GL + // account the counter deposited it to, which lives in m_cashier_deposit_request. Adopt + // that approved deposit as a collection so the member's voucher resolves here. Which GL + // account to look in is never guessed from product configuration — the counter selects it + // by hand every time, since a product mapping can silently go missing or point at the + // wrong account and a plain voucher lookup should never depend on that being right. + final Map adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride); if (adopted != null) { return Response.ok(gson.toJson(adopted)).build(); } throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher " - + voucherNo + ". Check the number, or confirm the deposit has been approved by a manager."); + + voucherNo + " in GL account " + glAccountNoOverride + ". Check the voucher number, the GL account, and confirm the " + + "deposit has been approved by a manager."); } final Map row = rows.get(0); @@ -253,6 +256,55 @@ public class ShareAdvanceCollectionApiResource { return Response.ok(gson.toJson(row)).build(); } + // ══════════════════════════════════════════════════════════════════════ + // GET — GL accounts selectable for a by-voucher lookup + // ══════════════════════════════════════════════════════════════════════ + /** + * Every active, postable GL account, for the GL account picker on the Apply Collection screen. + * + * Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to + * point at today — that mapping is exactly what this lookup no longer trusts, since it can go + * missing or wrong without anyone noticing until a voucher search fails at the counter. The person + * applying a receipt picks the account the deposit actually went to, every time. + */ + @GET + @Path("/glaccounts") + public Response glAccounts() { + this.context.authenticatedUser(); + final List> rows = jdbcTemplate.queryForList( + "SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code"); + final Map out = new LinkedHashMap<>(); + out.put("glAccounts", rows); + return Response.ok(gson.toJson(out)).build(); + } + + // ══════════════════════════════════════════════════════════════════════ + // GET — What a share account needs before a collection can be applied + // ══════════════════════════════════════════════════════════════════════ + /** + * Share value plus any outstanding entry fee for one share account -- shown up front on the Apply + * Collection screen so the person at the counter knows the full amount to collect (and that it may + * come in as more than one receipt, e.g. one for the share value and a separate one for the fee) + * before a search ever comes up short, rather than learning it only from an error after the fact. + */ + @GET + @Path("/requirement") + public Response requirement(@QueryParam("shareAccountId") final Long shareAccountId) { + this.context.authenticatedUser(); + if (shareAccountId == null) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required."); + } + final Map acct = loadAccount(shareAccountId); + final BigDecimal shareAmount = shareValueOf(shareAccountId); + final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); + final Map out = new LinkedHashMap<>(); + out.put("accountNo", acct.get("account_no")); + out.put("shareAmount", shareAmount); + out.put("chargeAmount", chargeAmount); + out.put("required", shareAmount.add(chargeAmount)); + return Response.ok(gson.toJson(out)).build(); + } + // ══════════════════════════════════════════════════════════════════════ // POST — Apply a receipt to an activated share account // ══════════════════════════════════════════════════════════════════════ @@ -273,7 +325,8 @@ public class ShareAdvanceCollectionApiResource { // A member who pays in instalments has more than one receipt against the same application: // ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they // are applied together as one settlement. The path receipt leads; any others come in the - // body. + // body. These are the SHARE-side receipts only -- money that actually sat in Sundry and + // genuinely needs to move out of it into Share Capital. final List collectionIds = new java.util.ArrayList<>(); collectionIds.add(collectionId); for (final Long additionalId : getLongList(json, "additionalCollectionIds")) { @@ -282,6 +335,14 @@ public class ShareAdvanceCollectionApiResource { } } + // A separately-verified entry-fee receipt, when the member's fee was collected as its own + // receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry. + // This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL + // entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave + // null) when the fee was paid as part of the same Sundry receipt(s) as the share value -- + // that legacy, single-receipt case still needs the fee amount swept out of Sundry below. + final Long chargeCollectionId = getLong(json, "chargeCollectionId"); + final List> collections = new java.util.ArrayList<>(); for (final Long id : collectionIds) { final Map row = loadCollection(id); @@ -292,6 +353,15 @@ public class ShareAdvanceCollectionApiResource { collections.add(row); } + Map chargeColl = null; + if (chargeCollectionId != null) { + chargeColl = loadCollection(chargeCollectionId); + if (!"UNAPPLIED".equals(chargeColl.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied", + "Entry fee receipt " + chargeColl.get("receipt_no") + " is already " + chargeColl.get("status") + "."); + } + } + final Map coll = collections.get(0); final Map acct = loadAccount(shareAccountId); @@ -306,6 +376,10 @@ public class ShareAdvanceCollectionApiResource { + " belongs to a different member than share account " + acct.get("account_no") + "."); } } + if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt " + + chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + "."); + } final int statusEnum = toInt(acct.get("status_enum")); if (statusEnum != STATUS_ACTIVE) { @@ -320,7 +394,12 @@ public class ShareAdvanceCollectionApiResource { // Share value the account was opened for, and any entry fee attached to it. final BigDecimal shareAmount = shareValueOf(shareAccountId); final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); - final BigDecimal required = shareAmount.add(chargeAmount); + + // When the fee is being verified as its own receipt, the Sundry-side receipts only need to + // cover the share value -- the fee is proved paid separately below, and its money is never + // expected to be part of what came out of Sundry. Without a separate fee receipt, the fee is + // assumed bundled into the same Sundry receipt(s) as the share value, exactly as before. + final BigDecimal requiredForShareReceipts = chargeColl != null ? shareAmount : shareAmount.add(chargeAmount); BigDecimal receiptAmount = BigDecimal.ZERO; final StringBuilder receiptNos = new StringBuilder(); @@ -335,23 +414,41 @@ public class ShareAdvanceCollectionApiResource { // Short is blocked: share capital cannot be part-issued — the member either bought those // shares or did not — so settling for less would leave the account short by an amount owed // by nobody. The clerk collects the balance on another receipt and applies both together. - if (receiptAmount.compareTo(required) < 0) { - final BigDecimal shortfall = required.subtract(receiptAmount); + if (receiptAmount.compareTo(requiredForShareReceipts) < 0) { + final BigDecimal shortfall = requiredForShareReceipts.subtract(receiptAmount); throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short", "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires " - + required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall + + requiredForShareReceipts + ". Collect the remaining " + shortfall + " from the member, then apply both receipts together."); } + // The fee receipt, when given, must cover the fee on its own -- checked separately from the + // share-side shortfall above, since the two are no longer one combined pot. + if (chargeColl != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0) { + final BigDecimal chargeReceiptAmount = (BigDecimal) chargeColl.get("amount"); + if (chargeReceiptAmount.compareTo(chargeAmount) < 0) { + final BigDecimal shortfall = chargeAmount.subtract(chargeReceiptAmount); + throw new PlatformDataIntegrityException("error.msg.shareadvance.charge.amount.short", + "Entry fee receipt " + chargeColl.get("receipt_no") + " totals " + chargeReceiptAmount + " but share account " + + acct.get("account_no") + " requires " + chargeAmount + " for the entry fee. Collect the remaining " + + shortfall + " from the member."); + } + } + // Over-payment is allowed through rather than turning a member away over a rounded-up note. - // The difference is credited to their linked savings account below. - final BigDecimal excessAmount = receiptAmount.subtract(required); + // The difference is credited to their linked savings account below. Only the share-side + // excess is handled here -- an over-payment on a separately-verified fee receipt would need + // its own GL leg to fund the savings credit, which this flow does not post (that receipt's + // money is left exactly where the cashier already put it), so it is left uncollected rather + // than silently unbalancing the ledger. + final BigDecimal excessAmount = receiptAmount.subtract(requiredForShareReceipts); final Long savingsAccountId = toLong(acct.get("savings_account_id")); if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) { throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings", "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") - + " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings " - + "account, but none is linked to this share account. Link one, or collect the exact amount."); + + " requires only " + requiredForShareReceipts + ". The extra " + excessAmount + + " must be credited to the member's savings account, but none is linked to this share account. Link one, or " + + "collect the exact amount."); } final Long officeId = toLong(acct.get("office_id")); @@ -367,8 +464,9 @@ public class ShareAdvanceCollectionApiResource { final String txnId = "SHAPP-" + System.nanoTime(); final String accountNo = (String) acct.get("account_no"); - // Dr Sundry for everything collected, including any over-payment — the liability those - // receipts created is cleared in full, in one leg, so nothing is left behind in suspense. + // Dr Sundry for the share-side receipts, including any over-payment on them — the liability + // those receipts created is cleared in full. A verified fee receipt is deliberately excluded: + // it was never in Sundry, so nothing here should debit Sundry for it. insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo); @@ -376,14 +474,19 @@ public class ShareAdvanceCollectionApiResource { insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share capital issued for " + accountNo); - // Cr Income from Fees for the entry fee, when one was charged. + // Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt + // above -- that money already sits in whichever GL the cashier deposited it to (often the fee + // income account itself), so moving it again here would double it. When it was bundled into + // the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs + // to move, exactly as before. if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { - // Resolved from the share product's "Income from Fees" mapping, which points at the - // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. - 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); - + if (chargeColl == null) { + // Resolved from the share product's "Income from Fees" mapping, which points at the + // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. + 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); + } 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); } @@ -399,23 +502,39 @@ public class ShareAdvanceCollectionApiResource { } // Stamp the originating cashier deposits so the same vouchers cannot be applied twice. + // Matched on voucher_no alone -- voucher numbers are unique across the whole cashier deposit + // table regardless of which GL account they were posted to, so no GL account lookup is needed + // here either. Covers the fee receipt's own deposit too, when one was verified. for (final Map row : collections) { if (row.get("voucher_no") != null) { jdbcTemplate.update( "UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, " - + " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL", - shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO); + + " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no")); } } + if (chargeColl != null && chargeColl.get("voucher_no") != null) { + jdbcTemplate.update( + "UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, " + + " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeColl.get("voucher_no")); + } // Every receipt in this settlement is marked applied, not just the one in the path — they - // were all consumed by it, and an unmarked one would still show as money awaiting use. + // were all consumed by it, and an unmarked one would still show as money awaiting use. The fee + // receipt is marked applied too, even though it posted no GL entry of its own. for (final Long id : collectionIds) { jdbcTemplate.update( "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id); } + if (chargeCollectionId != null) { + jdbcTemplate.update( + "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " + + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", + shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId); + } // Reveals the purchase transaction in the passbook and transactions views. jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); @@ -428,7 +547,8 @@ public class ShareAdvanceCollectionApiResource { out.put("accountNo", accountNo); out.put("shareAmount", shareAmount); out.put("chargeAmount", chargeAmount); - out.put("totalApplied", required); + out.put("chargeReceiptVerifiedSeparately", chargeColl != null); + out.put("totalApplied", requiredForShareReceipts); out.put("totalCollected", receiptAmount); out.put("glTransactionId", txnId); out.put("status", "APPLIED"); @@ -458,16 +578,25 @@ public class ShareAdvanceCollectionApiResource { * clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which * is what keeps the money tied to the right member. * - * @return the newly created collection row, or null when no approved Sundry deposit matches. + * @param glAccountNoOverride + * required -- the GL account number the deposit was posted to, selected by the counter + * rather than resolved from any product's accounting configuration, so a broken or + * missing product mapping can never make a genuine voucher unreachable. + * @return the newly created collection row, or null when no approved deposit matches. */ - private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) { + private Map adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) { + if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required", + "Select the GL account this voucher was deposited to, then search again."); + } + final String shareSundryAccountNo = glAccountNoOverride.trim(); final List> deposits = jdbcTemplate .queryForList( "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id," + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id" + " FROM m_cashier_deposit_request d" + " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC", - voucherNo, SHARE_SUNDRY_ACCOUNT_NO); + voucherNo, shareSundryAccountNo); if (deposits.isEmpty()) { return null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 30dbe7b23..6301a771a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -324,7 +324,7 @@ public class ClerkShareWithdrawalRequestApiResource { final Long requestedShares = ((Number) record.get("requested_shares")).longValue(); final Map shareAccountRow = jdbcTemplate.queryForList(""" - SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, + SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, collection_applied AS collectionApplied, savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId FROM m_share_account WHERE id = ? """, shareAccountId).stream().findFirst().orElse(null); @@ -340,6 +340,14 @@ public class ClerkShareWithdrawalRequestApiResource { : -1; checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus))); + // Active only means approved and activated -- not paid for. Real share capital is only posted + // once a Share Advance Collection is applied (see ShareAdvanceCollectionApiResource), and + // approve() below hard-blocks on this same flag, so a request failing this check can never + // actually be approved regardless of what a checker decides here. + final boolean collectionApplied = shareAccountRow != null && Boolean.TRUE.equals(shareAccountRow.get("collectionApplied")); + checks.add(checkResult("Shares have been paid for (Share Advance Collection applied)", collectionApplied, + collectionApplied ? "Settled" : "Not settled — no collection has been applied to this share account yet")); + final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null ? ((Number) shareAccountRow.get("savingsAccountId")).longValue() : null; @@ -589,6 +597,20 @@ public class ClerkShareWithdrawalRequestApiResource { final Object meetingDateObj = record.get("meeting_date"); final String resolutionNumber = (String) record.get("resolution_number"); + // Account creation, approval and activation post no share-capital GL of their own -- a share + // account can be Active with "approved" shares that nobody has actually paid for yet, and only + // gets a real Dr Share Sundry / Cr Share Capital entry once a collection is applied (see + // ShareAdvanceCollectionApiResource). Redeeming before that would reverse capital that was + // never issued, so this is a hard block here, not just an advisory line in validate()'s + // checklist -- a checker approving off that checklist alone has no other backstop. + final Boolean collectionApplied = jdbcTemplate + .queryForList("SELECT collection_applied AS collectionApplied FROM m_share_account WHERE id = ?", shareAccountId).stream() + .findFirst().map(row -> (Boolean) row.get("collectionApplied")).orElse(null); + if (!Boolean.TRUE.equals(collectionApplied)) { + throw apiError(409, "Share account " + shareAccountId + " has not been settled yet -- no Share Advance Collection has been " + + "applied to it, so these shares have not actually been paid for and cannot be redeemed."); + } + final JsonObject payload = new JsonObject(); payload.addProperty("locale", "en"); payload.addProperty("dateFormat", "dd MMMM yyyy"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/serialization/ShareProductDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/serialization/ShareProductDataSerializer.java index fa7bc44c1..10d81adf5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/serialization/ShareProductDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/serialization/ShareProductDataSerializer.java @@ -395,7 +395,14 @@ public class ShareProductDataSerializer { } if (this.fromApiJsonHelper.parameterExists(ShareProductApiConstants.charges_paramname, element)) { - final String currencyCode = this.fromApiJsonHelper.extractStringNamed(ShareProductApiConstants.currency_paramname, element); + // Falls back to the product's own current currency when the update payload doesn't carry + // one -- a maker-checker edit that only touched charges replays a JSON containing just the + // submitted fields, with no currencyCode of its own, so requiring it here would make every + // charges-only edit crash rather than validate against the currency the product already has. + String currencyCode = this.fromApiJsonHelper.extractStringNamed(ShareProductApiConstants.currency_paramname, element); + if (currencyCode == null && product.getCurrency() != null) { + currencyCode = product.getCurrency().getCode(); + } Set charges = assembleListOfProductCharges(element, currencyCode); if (product.setCharges(charges)) { actualChanges.put(ShareProductApiConstants.charges_paramname, charges); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java index ef128c757..bbfa1822a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareproducts/service/ShareProductWritePlatformServiceJpaRepositoryImpl.java @@ -865,7 +865,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, fieldName)); } - final List chargeIds = ProductPendingEditFieldMapper.extractChargeIds(command); + final List chargeIds = ProductPendingEditFieldMapper.extractChargeIds(command, ShareProductApiConstants.charges_paramname); if (chargeIds != null) { this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, "charges")); for (final Long chargeId : chargeIds) { @@ -907,7 +907,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } if (submittedFields.contains("charges")) { ProductPendingEditFieldMapper.writeChargesToJson(root, - this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId())); + this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId()), ShareProductApiConstants.charges_paramname); } if (submittedFields.contains("glArrayMappings")) { ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root, @@ -944,7 +944,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP } if (submittedFields.contains("charges")) { ProductPendingEditFieldMapper.writeChargesToJson(root, - this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId())); + this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId()), ShareProductApiConstants.charges_paramname); } if (submittedFields.contains("glArrayMappings")) { ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root, diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 70fbe79ba..e39a6cdd6 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java index c6f50ba70..e7cfd0d76 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -547,10 +547,21 @@ public final class ProductPendingEditFieldMapper { /** Returns null if "charges" wasn't present at all, else the (possibly empty) list of charge ids. */ public static List extractChargeIds(final JsonCommand command) { - if (!command.hasParameter("charges")) { + return extractChargeIds(command, "charges"); + } + + /** + * Same as {@link #extractChargeIds(JsonCommand)}, but reads a caller-chosen JSON field instead of the + * "charges" every other product type uses. Share products alone submit this array under + * {@code chargesSelected} (see {@code ShareProductApiConstants.charges_paramname}) -- calling the + * default overload for a share product silently finds nothing, since {@code hasParameter("charges")} + * is always false for that payload. + */ + public static List extractChargeIds(final JsonCommand command, final String paramName) { + if (!command.hasParameter(paramName)) { return null; } - final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get("charges"); + final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get(paramName); final List ids = new java.util.ArrayList<>(); if (raw != null && raw.isJsonArray()) { for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) { @@ -563,13 +574,23 @@ public final class ProductPendingEditFieldMapper { } public static void writeChargesToJson(final JsonObject root, final List charges) { + writeChargesToJson(root, charges, "charges"); + } + + /** + * Same as {@link #writeChargesToJson(JsonObject, List)}, but writes under a caller-chosen field name. + * The replayed JSON built here is fed straight back into the same product-update parser the original + * submission went through, so the field name must match whatever that product type's own submit + * payload used -- {@code chargesSelected} for share products, {@code charges} for every other type. + */ + public static void writeChargesToJson(final JsonObject root, final List charges, final String paramName) { final JsonArray array = new JsonArray(); for (final ProductPendingEditCharge charge : charges) { final JsonObject entry = new JsonObject(); entry.addProperty("id", charge.getChargeId()); array.add(entry); } - root.add("charges", array); + root.add(paramName, array); } // ---- accountFacilityLimits (1:many, satellite table) ------------------------------------------- diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 414134b27..5c6eea230 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -355,7 +355,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom * {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures * rather than one bucket doing double duty. */ @Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true) - private BigDecimal savingsOnHoldAmountClearing; + private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO; @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) protected List identifiers = new ArrayList<>();