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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +