From dd4bb4a1c08ece227990e9448a208a72c0d9c674 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 18 Sep 2026 18:58:40 +0530 Subject: [PATCH] feat(shares, cashier): enforce Share Collection entry type on listing, single receipt fetch, product summary Only vouchers tagged with the "Share Collection" entry type reach a share account. The adoption path already checked this, but the listing did not, so an already-adopted "General" voucher kept reappearing at Apply Collection and Apply Additional Shares time. SHARE_COLLECTION_TAGGED_ONLY now filters the listing query itself, against both the cashier-deposit and clerk-disbursement request tables. /unattributed takes an optional clientId and answers with both lists in one response -- that member's own unapplied receipts under "own", every other available receipt under "rows", with the member's own excluded from the latter. The two endpoints read the same table under the same filters, so asking twice only ever fetched the same rows twice, and keeping the query in two places is what let the entry-type filter land on one and miss the other. Both now share unappliedFor(). The entry fee is never posted a second time. A receipt already sitting in an INCOME GL was recognised as income by the cashier's own entry when it was taken, so applying the collection now skips both legs for it and counts it only towards verifying the fee was collected. Adds GET /sharereports/product-summary: authorised, issued, available and unpaid shares per product, with the distinctive range and account count. Adds a series-overlap pre-check to sponsor DD inventory registration, which previously surfaced as a bare duplicate-key error. Adds migration 0568 for the columns the clerk share-withdrawal settlement path reads (settlement_mode, tok_no, cash_settlement_txn_id, cashier_id, disbursed_by, disbursed_at, disbursement_denomination_data), each changeset guarded so it is a no-op where the column already exists. Co-Authored-By: Claude Opus 5 (1M context) --- .../ShareAdvanceCollectionApiResource.java | 131 +++++++++++++++--- .../api/ShareReportsApiResource.java | 84 +++++++++++ .../SponsorDDWritePlatformServiceImpl.java | 15 ++ .../tenant/final-changelog-tenant.xml | 1 + ...mode_to_clerk_share_withdrawal_request.xml | 119 ++++++++++++++++ 5 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml 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 4874057f9..b3164ea4a 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 @@ -97,12 +97,42 @@ public class ShareAdvanceCollectionApiResource { private static final int DEBIT = 2; private static final int CREDIT = 1; + // ── acc_gl_account.classification_enum ───────────────────────────────── + // Fineract's GLAccountType: 1 = ASSET, 2 = LIABILITY, 3 = EQUITY, 4 = INCOME, 5 = EXPENSE. + private static final int GL_CLASSIFICATION_INCOME = 4; + // ── CashAccountsForShares financial_account_type ─────────────────────── private static final int FA_SHARES_REFERENCE = 1; 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; + /** + * Restricts a {@code m_share_advance_collection c} query to receipts whose money was actually + * collected FOR a share collection, and nothing else. + * + *

+ * The Entry Type the counter picks at save time is the only thing that says what a GL receipt was + * taken for, so it decides this too: a receipt left General, or tagged for some other purpose + * entirely, never reaches an Apply Collection or Additional Shares screen. Guarding only the + * adoption path was not enough -- a receipt adopted before that guard existed stayed on these + * lists for ever, which is exactly how a General voucher kept reappearing against share accounts. + * + *

+ * A collection with no voucher of its own is the one thing this lets through: those come from the + * direct Share Advance Collection endpoint, which exists for nothing but share collections, so + * there is no counter tag to read and none needed. + */ + private static final String SHARE_COLLECTION_TAGGED_ONLY = """ + AND (c.voucher_no IS NULL + OR EXISTS (SELECT 1 FROM m_cashier_deposit_request d + JOIN m_code_value dcv ON dcv.id = d.entry_type_cv_id + WHERE d.voucher_no = c.voucher_no AND dcv.code_value = 'Share Collection') + OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request r + JOIN m_code_value rcv ON rcv.id = r.entry_type_cv_id + WHERE r.voucher_no = c.voucher_no AND rcv.code_value = 'Share Collection')) + """; + // ── acc_product_mapping.product_type for shares ──────────────────────── private static final int PRODUCT_TYPE_SHARES = 4; @@ -198,6 +228,21 @@ public class ShareAdvanceCollectionApiResource { // needs the by-voucher search below, same as before. autoAdoptShareCollectionTagged(); + final List> rows = unappliedFor(clientId); + final Map out = new LinkedHashMap<>(); + out.put("unappliedCollections", rows); + out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add)); + return Response.ok(gson.toJson(out)).build(); + } + + /** + * Unapplied receipts ready to be ticked, for one member or for everybody. + * + * Defined once and read by both this endpoint and {@link #listUnattributed}, which returns the + * same rows under {@code own} so an Apply screen needs a single call rather than two that were + * only ever querying the same table with the same filters. + */ + private List> unappliedFor(final Long clientId) { final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id," + " cl.display_name AS client_name, " + " to_char(c.collection_date, 'YYYY-MM-DD') AS collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," @@ -207,17 +252,13 @@ public class ShareAdvanceCollectionApiResource { + " (SELECT ga.gl_code FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + " WHERE je.transaction_id = c.gl_transaction_id AND je.type_enum = " + CREDIT + " AND je.reversed = false" + " ORDER BY je.id LIMIT 1) AS gl_code " - + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' "); + + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' " + + SHARE_COLLECTION_TAGGED_ONLY); if (clientId != null) { sql.append(" AND c.client_id = ").append(clientId); } sql.append(" ORDER BY c.collection_date, c.id"); - - final List> rows = jdbcTemplate.queryForList(sql.toString()); - final Map out = new LinkedHashMap<>(); - out.put("unappliedCollections", rows); - out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add)); - return Response.ok(gson.toJson(out)).build(); + return jdbcTemplate.queryForList(sql.toString()); } // ══════════════════════════════════════════════════════════════════════ @@ -253,9 +294,17 @@ public class ShareAdvanceCollectionApiResource { * mismatch rather than silently moving the money -- this list only makes the voucher visible, it * does not reassign it. */ + /** + * @param clientId + * the member whose own Apply screen this is. Their own unapplied receipts come back + * separately under {@code own} -- the same rows, in the same shape, that + * {@link #listUnapplied} returns -- so a screen needs ONE call rather than two, and + * those rows are left out of {@code rows} so the same receipt is never listed twice on + * one screen. Omit it and everything available comes back under {@code rows}. + */ @GET @Path("/unattributed") - public Response listUnattributed() { + public Response listUnattributed(@QueryParam("clientId") final Long clientId) { this.context.authenticatedUser(); // Same eager conversion as listUnapplied() -- run unconditionally (no client filter) so a @@ -311,10 +360,15 @@ public class ShareAdvanceCollectionApiResource { + " WHERE c.status = 'UNAPPLIED'" + " AND ((cdr.id IS NULL AND clr.id IS NULL)" + " OR COALESCE(cdr_cv.code_value, clr_cv.code_value) = 'Share Collection')" + // This member's own receipts come back under "own" below, ready to tick; listing + // them here as well would put the same receipt on screen twice, once needing a + // tick and once an Attach, for no difference in what it is. + + (clientId != null ? " AND c.client_id <> " + clientId : "") + " ORDER BY \"txnDate\" DESC"); final Map out = new LinkedHashMap<>(); out.put("rows", rows); + out.put("own", clientId == null ? List.of() : unappliedFor(clientId)); return Response.ok(gson.toJson(out)).build(); } @@ -653,28 +707,47 @@ public class ShareAdvanceCollectionApiResource { // that had never received it, and left the account that had holding the balance for ever. // Debiting each receipt's own account is also what the clerk sees: the GL picked against the // voucher on the apply screen is the account being cleared here. + // + // A receipt that already sits in an INCOME-classified GL is left exactly where it is, not + // debited out here at all -- an entry fee the cashier deposited straight into its own income + // account (the ordinary case: "Share Entry", "Income From Fees", whatever the chart calls it) + // was already recognised as income the moment it was banked. Debiting it out and crediting a + // fresh income leg below for the same rupees would recognise it a second time -- the + // duplicate a counter tagging every entry-fee GL as Income now stops before it is ever posted. + // A ticket genuinely sitting in a non-income account (Share Sundry, most often -- the legacy + // single-receipt case where share value and fee arrived together) still needs clearing, so it + // is debited as before and its amount still moves to Income below. + BigDecimal feeAlreadyRecognised = BigDecimal.ZERO; for (final Map row : collections) { final Long receiptGl = receiptGlOf((String) row.get("gl_transaction_id"), glSuspense); - insertEntry(officeId, receiptGl, DEBIT, (BigDecimal) row.get("amount"), txnId, entryDate, shareAccountId, userId, now, - currencyCode, "Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo); + final BigDecimal rowAmount = (BigDecimal) row.get("amount"); + if (chargeColl == null && isIncomeGl(receiptGl)) { + feeAlreadyRecognised = feeAlreadyRecognised.add(rowAmount); + continue; + } + insertEntry(officeId, receiptGl, DEBIT, rowAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, + "Share application settled | Receipt " + row.get("receipt_no") + " | " + accountNo); } // Cr Share Capital for the share value. insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, "Share capital issued for " + accountNo); - // 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. + // Cr Income from Fees for the entry fee, but only for whatever part of it is not already + // sitting recognised as income (feeAlreadyRecognised, resolved above) -- that part already + // sits in whichever GL the cashier deposited it to, so moving it again here would double it. + // When it was bundled into the same Sundry receipt(s) as the share value (chargeColl == null) + // and genuinely never touched an income account, it still needs to move, exactly as before. if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { 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); + final BigDecimal feeStillToRecognise = chargeAmount.subtract(feeAlreadyRecognised); + if (feeStillToRecognise.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, feeStillToRecognise, 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); @@ -1182,6 +1255,24 @@ public class ShareAdvanceCollectionApiResource { return rows.isEmpty() ? fallbackGlId : rows.get(0); } + /** + * Whether a GL account is already an INCOME account (Fineract's own {@code classification_enum} + * for the "Income" account type on {@code acc_gl_account}). + * + *

+ * Used to tell an entry-fee receipt that already sits recognised as income (deposited straight + * into its own income GL by the cashier) apart from one still sitting in a holding account like + * Share Sundry — only the latter still needs moving into income here. + */ + private boolean isIncomeGl(final Long glAccountId) { + if (glAccountId == null) { + return false; + } + final Integer classification = jdbcTemplate.queryForObject( + "SELECT classification_enum FROM acc_gl_account WHERE id = ?", Integer.class, glAccountId); + return classification != null && classification == GL_CLASSIFICATION_INCOME; + } + private Long requireGl(final Long productId, final int financialAccountType, final String label) { final Long glId = resolveGl(productId, financialAccountType); if (glId == null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java index 1ac70b446..9f59cd2d3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareReportsApiResource.java @@ -2182,6 +2182,90 @@ public class ShareReportsApiResource { return args.toArray(); } + // ══════════════════════════════════════════════════════════════════════ + // GET /product-summary — each share class against its own authorised capital + // ══════════════════════════════════════════════════════════════════════ + /** + * Every share product's authorised capital measured against what has actually been paid up — + * how many of its shares are issued, how many remain, and the distinctive-number range issued so + * far — the numbers a board asks for when it wants to know how close a share class is to its own + * ceiling. + * + *

+ * "Issued" here is paid-up shares, computed the same way every other report in this file computes + * them (settled purchases less redemptions), not {@code m_share_product.totalsubscribed_shares}. + * That column is Fineract's own running count of shares allotted, updated the moment an + * application is submitted — before any money has necessarily arrived — so it already includes + * the allotments this whole file exists to separate out. A board reading "202 of 1,000 issued" + * when the GL Share Capital account shows only 152 shares' worth of money received would be + * planning against a ceiling that is not really as close as the platform's own figure claims. + */ + @GET + @Path("/product-summary") + @Operation(summary = "Share capital issued and remaining, by product", + description = "Authorised shares, paid-up shares issued, shares remaining, unpaid allotments and the distinctive-number range, per share product.") + public Response productSummary() { + this.context.authenticatedUser(); + + final List> rows = this.jdbcTemplate.queryForList(""" + SELECT sp.id AS "productId", + sp.name AS "productName", + sp.short_name AS "productShortName", + sp.unit_price AS "unitPrice", + sp.minimum_client_shares AS "minimumClientShares", + sp.nominal_client_shares AS "nominalClientShares", + sp.maximum_client_shares AS "maximumClientShares", + COALESCE(sp.total_shares, 0) AS "authorisedShares", + COALESCE(sp.total_shares, 0) * COALESCE(sp.unit_price, 0) AS "authorisedCapital", + COALESCE(issued.shares, 0) AS "issuedShares", + COALESCE(issued.shares, 0) * COALESCE(sp.unit_price, 0) AS "issuedCapital", + COALESCE(sp.total_shares, 0) - COALESCE(issued.shares, 0) AS "availableShares", + COALESCE(unpaid.accounts, 0) AS "unpaidAccounts", + COALESCE(unpaid.shares, 0) AS "unpaidShares", + COALESCE(unpaid.amount, 0) AS "unpaidAmount", + CASE WHEN COALESCE(issued.ever_allotted, 0) > 0 THEN 1 END AS "distinctiveFrom", + NULLIF(COALESCE(issued.ever_allotted, 0), 0) AS "distinctiveTo", + COALESCE(acct."accountCount", 0) AS "accountCount", + COALESCE(acct."memberCount", 0) AS "memberCount" + FROM m_share_product sp + LEFT JOIN ( + SELECT sa.product_id, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) + - SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS shares, + SUM(CASE WHEN t.type_enum = %d THEN COALESCE(t.total_shares, 0) ELSE 0 END) AS ever_allotted + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum IN (%d, %d) + AND (t.type_enum = 600 OR t.funding_source IS NOT NULL OR sa.collection_applied = true) + GROUP BY sa.product_id + ) issued ON issued.product_id = sp.id + LEFT JOIN ( + SELECT sa.product_id, + COUNT(*) AS accounts, + SUM(COALESCE(t.total_shares, 0)) AS shares, + SUM(COALESCE(t.amount, 0)) AS amount + FROM m_share_account_transactions t + JOIN m_share_account sa ON sa.id = t.account_id + WHERE t.is_active = true AND t.status_enum = %d AND t.type_enum = %d + AND t.funding_source IS NULL AND COALESCE(sa.collection_applied, false) = false + AND sa.status_enum <> %d + GROUP BY sa.product_id + ) unpaid ON unpaid.product_id = sp.id + LEFT JOIN ( + SELECT sa.product_id, COUNT(*) AS "accountCount", COUNT(DISTINCT sa.client_id) AS "memberCount" + FROM m_share_account sa + WHERE sa.status_enum = %d + GROUP BY sa.product_id + ) acct ON acct.product_id = sp.id + ORDER BY sp.name + """.formatted(TYPE_PURCHASED, TYPE_REDEEMED, TYPE_PURCHASED, TXN_APPROVED, TYPE_PURCHASED, TYPE_REDEEMED, + TXN_APPROVED, TYPE_PURCHASED, ACCOUNT_REJECTED, TXN_APPROVED)); + + final Map out = new LinkedHashMap<>(); + out.put("rows", rows); + return Response.ok(gson.toJson(out)).build(); + } + private long toLong(final Object value) { return value instanceof Number number ? number.longValue() : 0L; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java index 624162726..b8ebc9cdf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDWritePlatformServiceImpl.java @@ -108,6 +108,21 @@ public class SponsorDDWritePlatformServiceImpl implements SponsorDDWritePlatform throw new IllegalArgumentException("Series start number must be less than or equal to series end number."); } + // dd_number is globally unique across every inventory batch (sdd_leaf_unq_dd_number), but + // nothing before this checked a fresh batch's own range against numbers already registered -- + // the first colliding number failed the bulk insert below with a raw duplicate-key exception, + // rolling the whole batch back and leaving the counter reading a database stack trace instead + // of the actual problem: this range overlaps one already on file. + final List overlapping = this.jdbcTemplate.queryForList( + "SELECT dd_number FROM m_sponsor_dd_leaflet WHERE dd_number ~ '^[0-9]+$' " + + " AND dd_number::bigint BETWEEN ? AND ? ORDER BY dd_number::bigint LIMIT 5", + String.class, seriesStart, seriesEnd); + if (!overlapping.isEmpty()) { + throw new IllegalArgumentException("Series " + seriesStart + "-" + seriesEnd + + " overlaps DD number(s) already registered in another inventory batch (for example " + overlapping.get(0) + + "). Choose a series that does not overlap an existing one."); + } + final SponsorBank sponsorBank = this.sponsorBankRepository.findById(sponsorBankId) .orElseThrow(() -> new IllegalArgumentException("Sponsor Bank not found with ID: " + sponsorBankId)); final Office branch = this.officeRepositoryWrapper.findOneWithNotFoundDetection(branchId); 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 730b2fdd7..0ad5ba659 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 @@ -113,4 +113,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml new file mode 100644 index 000000000..1c0f105ec --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +