diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index c1588aa0e..7378801ec 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -128,6 +128,12 @@ public class CashierDepositRequestApiResource { final String bankName = fromJsonHelper.extractStringNamed("bankName", root); final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root); final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root); + // What the counter says this GL credit is actually for — a code value under the + // "GLEntryType" code (Admin > System > Manage Codes), defaulting to "General" unless the row + // is one of the flows below picks up its own money later (Share Advance Collection, Sponsor + // DD), in which case that adoption checks this tag so it can never take a voucher meant for + // the other one. See 0565_convert_entry_type_to_code_value.xml. + final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root)); // DD + Cash + GL breakdown from Transaction Entry - null for ordinary deposit requests. // Persisted so the Sponsor DD issuance screen can read the breakdown back instead of // retyping face value/charges once this request is approved and selected as cash evidence. @@ -160,8 +166,8 @@ public class CashierDepositRequestApiResource { client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -173,7 +179,7 @@ public class CashierDepositRequestApiResource { "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), denominationData, activityLog, Timestamp.from(Instant.now()), - ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount + ddFaceValue, ddCharges, ddGstPercentage, ddGstAmount, entryTypeCvId ); // Globally unique voucher number from the central m_voucher register @@ -228,7 +234,7 @@ public class CashierDepositRequestApiResource { + " txn_date, status, cashier_id, cashier_name, cashier_username," + " manager_id, manager_name, manager_username, rejection_reason, txn_id," + " denomination_data, activity_log, submitted_at, actioned_at," - + " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount" + + " dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id" + " FROM m_cashier_deposit_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -282,7 +288,7 @@ public class CashierDepositRequestApiResource { payment_type_name, description, txn_date, status, cashier_name, manager_name, manager_username, rejection_reason, txn_id, submitted_at, actioned_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id FROM m_cashier_deposit_request """ + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray() @@ -669,7 +675,7 @@ public class CashierDepositRequestApiResource { txn_date, status, cashier_id, cashier_name, cashier_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, denomination_data, activity_log, submitted_at, actioned_at, - dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount + dd_face_value, dd_charges, dd_gst_percentage, dd_gst_amount, entry_type_cv_id FROM m_cashier_deposit_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -1032,6 +1038,18 @@ public class CashierDepositRequestApiResource { } } + /** The counter's choice, when they made one — otherwise "General" under the GLEntryType code, + * resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */ + private Long resolveEntryTypeCvId(final Long provided) { + if (provided != null) { + return provided; + } + return jdbcTemplate.queryForObject( + "SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id" + + " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'", + Long.class); + } + private String buildActivityLog(final String existingJson, final String action, final String username, final String note) { JsonArray log = new JsonArray(); 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 c0c72ff3b..23004022d 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 @@ -188,11 +188,25 @@ public class ShareAdvanceCollectionApiResource { // GET — Unapplied receipts, optionally for one client // ══════════════════════════════════════════════════════════════════════ @GET + @Transactional public Response listUnapplied(@QueryParam("clientId") final Long clientId) { this.context.authenticatedUser(); + // A voucher explicitly tagged Share Collection at save time no longer waits for the counter + // to search it up by hand -- it's pulled onto this list itself, the first time anyone asks + // for it. Anything not tagged this way (an older row, or one deliberately left General) still + // needs the by-voucher search below, same as before. + autoAdoptShareCollectionTagged(clientId); + final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id," - + " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note " + + " cl.display_name AS client_name, " + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," + // The GL account this specific receipt's money actually sits in -- its own posting's + // credit leg, read back from acc_gl_journal_entry rather than guessed, so the GL + // Account picker above can be kept in sync with whichever receipt gets ticked. + + " (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' "); if (clientId != null) { sql.append(" AND c.client_id = ").append(clientId); @@ -233,7 +247,8 @@ public class ShareAdvanceCollectionApiResource { // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. final List> rows = jdbcTemplate .queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id " + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name," + + " c.applied_share_account_id " + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); @@ -273,19 +288,42 @@ public class ShareAdvanceCollectionApiResource { // GET — GL accounts selectable for a by-voucher lookup // ══════════════════════════════════════════════════════════════════════ /** - * Every active, postable GL account, for the GL account picker on the Apply Collection screen. + * The GL account picker on the Apply Collection screen — narrowed to accounts that actually have + * an APPROVED, not-yet-consumed cashier deposit or clerk collection sitting in them right now, + * so the counter is choosing from real, live money rather than the entire chart of accounts + * (which is every postable GL account in the bank, share-related or not, well over a hundred of + * them, Cash at Vault included, with nothing to say which one has anything to find). * - * 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. + * Still never resolved from a share product's own Share Suspense mapping -- 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. This filters by what genuinely sits in + * each account today, not by what any product's configuration claims should. Falls back to the + * unfiltered full list on the rare chance nothing currently qualifies, so the picker is never a + * dead end -- a voucher search should not become unreachable just because this narrowing has + * nothing to narrow to yet. */ @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"); + List> rows = jdbcTemplate.queryForList( + "SELECT DISTINCT ga.id, ga.gl_code, ga.name" + + " FROM acc_gl_account ga" + + " WHERE ga.disabled = false AND ga.account_usage = 1" + + " AND (" + + " EXISTS (SELECT 1 FROM m_cashier_deposit_request d WHERE d.gl_account_id = ga.id" + + " AND d.status = 'APPROVED' AND d.share_account_id IS NULL" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)" + + " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id))" + + " OR EXISTS (SELECT 1 FROM m_clerk_disbursement_request d WHERE d.gl_account_id = ga.id" + + " AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no))" + + " )" + + " ORDER BY ga.gl_code"); + if (rows.isEmpty()) { + 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(); @@ -318,6 +356,47 @@ public class ShareAdvanceCollectionApiResource { return Response.ok(gson.toJson(out)).build(); } + // ══════════════════════════════════════════════════════════════════════ + // GET — The actual Dr/Cr GL legs a receipt posted, for the details popup + // ══════════════════════════════════════════════════════════════════════ + /** + * What this receipt actually did to the ledger — the real Dr/Cr legs under its own + * {@code gl_transaction_id}, not the GL account currently picked in the by-voucher search box + * (a different, unrelated piece of screen state that a details popup must never be shown + * against, since it can name a completely different account than the one this receipt sits in). + */ + @GET + @Path("/{collectionId}/gl-entries") + public Response glEntries(@PathParam("collectionId") final Long collectionId) { + this.context.authenticatedUser(); + final List> collRows = jdbcTemplate + .queryForList("SELECT gl_transaction_id FROM m_share_advance_collection WHERE id = ?", collectionId); + if (collRows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found", "Receipt " + collectionId + " not found."); + } + final String glTransactionId = (String) collRows.get(0).get("gl_transaction_id"); + final List> legs = glTransactionId == null ? List.of() + : jdbcTemplate.queryForList( + "SELECT ga.gl_code, ga.name AS account_name, je.type_enum, je.amount" + + " FROM acc_gl_journal_entry je JOIN acc_gl_account ga ON ga.id = je.account_id" + + " WHERE je.transaction_id = ? AND je.reversed = false ORDER BY je.type_enum DESC, je.id", + glTransactionId); + + final Map out = new LinkedHashMap<>(); + out.put("transactionId", glTransactionId); + final List> entries = new java.util.ArrayList<>(); + for (final Map leg : legs) { + final Map e = new LinkedHashMap<>(); + e.put("glCode", leg.get("gl_code")); + e.put("accountName", leg.get("account_name")); + e.put("drCr", toInt(leg.get("type_enum")) == DEBIT ? "Dr" : "Cr"); + e.put("amount", leg.get("amount")); + entries.add(e); + } + out.put("entries", entries); + return Response.ok(gson.toJson(out)).build(); + } + // ══════════════════════════════════════════════════════════════════════ // POST — Apply a receipt to an activated share account // ══════════════════════════════════════════════════════════════════════ @@ -609,6 +688,108 @@ public class ShareAdvanceCollectionApiResource { * missing product mapping can never make a genuine voucher unreachable. * @return the newly created collection row, or null when no approved deposit matches. */ + /** + * Pulls every APPROVED, not-yet-consumed deposit or clerk collection explicitly tagged "Share + * Collection" into m_share_advance_collection, so it shows up on the Apply Share / Additional + * Share screen on its own -- the point of tagging a voucher this way at save time in the first + * place. + * + * A cashier Receipt straight into a GL account (AC.TP=GL) names no member of its own -- + * buildDepositPayload() in transaction-entry.component.ts sends clientId:null for every one of + * these, deliberately, since a GL account isn't anybody's account. Such a row is NEVER + * auto-adopted, for any client, under any circumstances -- attributing it to "whichever member's + * screen happened to be open when this ran" is a coincidence of timing, not evidence of whose + * money it is, and would silently hand one member's cash to a completely unrelated member simply + * because their Apply Share screen loaded first. A clientless receipt can only ever be claimed + * through the manual by-voucher search, where a person at the counter is actively vouching for it + * against a member they can see, by quoting the specific voucher number the member is holding -- + * a deliberate, checkable act, not an automatic guess. A row that DOES carry its own client_id + * (a Clerk Collection, whose client comes from the savings account it actually debited -- real + * evidence, not a guess) is still only adopted against a matching client_id, never a different + * member than the one asking. + */ + private void autoAdoptShareCollectionTagged(final Long clientIdFilter) { + final List> deposits = jdbcTemplate.queryForList( + "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.office_id, d.currency_code, d.txn_date, d.txn_id," + + " d.cashier_id, d.cashier_name, d.description" + + " FROM m_cashier_deposit_request d" + + " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + + " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.share_account_id IS NULL" + + " AND d.client_id IS NOT NULL" + + " AND NOT EXISTS (SELECT 1 FROM m_sponsor_dd_issue_request s WHERE s.cashier_deposit_request_id = d.id)" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"); + for (final Map dep : deposits) { + final Long resolvedClientId = toLong(dep.get("client_id")); + // Guaranteed non-null by the WHERE clause above, but a genuine client_id must still match + // who is asking -- never adopted for a different member than the one whose screen it is. + if (clientIdFilter != null && !clientIdFilter.equals(resolvedClientId)) { + continue; + } + final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date + ? ((java.sql.Date) dep.get("txn_date")).toLocalDate() + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String receiptNo = nextReceiptNo(); + // What the cashier actually typed at save time, when they typed anything -- that is what + // the Apply Share screen's Description column exists to show. Falls back to a plain + // label only for a row nobody described, same as the manual by-voucher adoption below. + final String depDescription = (String) dep.get("description"); + final String note = depDescription != null && !depDescription.isBlank() ? depDescription + : "Share collection - Voucher " + dep.get("voucher_no"); + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, dep.get("voucher_no"), resolvedClientId, toLong(dep.get("office_id")), + java.sql.Date.valueOf(collectionDate), dep.get("amount"), dep.get("currency_code"), dep.get("txn_id"), + toLong(dep.get("cashier_id")), dep.get("cashier_name"), note); + } + + final List> collections = jdbcTemplate.queryForList( + "SELECT d.id, d.voucher_no, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name," + + " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id" + + " FROM m_clerk_disbursement_request d" + + " JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + + " WHERE cv.code_value = 'Share Collection' AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'" + + " AND NOT EXISTS (SELECT 1 FROM m_share_advance_collection sc WHERE sc.voucher_no = d.voucher_no)"); + for (final Map req : collections) { + final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries")); + if (savingsAccountId == null) { + continue; + } + final Long memberClientId = jdbcTemplate.queryForObject("SELECT client_id FROM m_savings_account WHERE id = ?", Long.class, + savingsAccountId); + if (clientIdFilter != null && !clientIdFilter.equals(memberClientId)) { + continue; + } + final Long officeId = req.get("office_id") != null ? toLong(req.get("office_id")) : loadClientOfficeId(memberClientId); + final String currency = req.get("currency_code") != null ? (String) req.get("currency_code") : "INR"; + final Object actionedAt = req.get("actioned_at"); + final Object submittedAt = req.get("submitted_at"); + final LocalDate collectionDate = (actionedAt != null ? actionedAt : submittedAt) instanceof java.sql.Timestamp + ? ((java.sql.Timestamp) (actionedAt != null ? actionedAt : submittedAt)).toLocalDateTime().toLocalDate() + : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final String voucherNo = (String) req.get("voucher_no"); + final String receiptNo = nextReceiptNo(); + final String reqDescription = (String) req.get("description"); + final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription + : "Share collection - Voucher " + voucherNo; + // The real acc_gl_journal_entry.transaction_id this approval posted (see + // 0566_add_gl_transaction_id_to_clerk_disbursement_request.xml) -- falls back to the + // voucher number only for a row approved before that column existed, since something has + // to go in a NOT NULL-shaped identity column and the voucher number at least resolves + // back to this same row. + final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo; + jdbcTemplate.update( + "INSERT INTO m_share_advance_collection " + + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + + " gl_transaction_id, collected_by_user, collected_by_name, note) " + + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", + receiptNo, voucherNo, memberClientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, + glTxnId, toLong(req.get("clerk_id")), req.get("clerk_name"), note); + } + } + 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", @@ -618,8 +799,10 @@ public class ShareAdvanceCollectionApiResource { 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" + + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id," + + " cv.code_value AS entry_type" + " FROM m_cashier_deposit_request d" + + " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC", voucherNo, shareSundryAccountNo); @@ -632,6 +815,23 @@ public class ShareAdvanceCollectionApiResource { throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied", "Voucher " + voucherNo + " has already been applied to a share account."); } + // The counter tags what a GL receipt was actually collected for at save time, from the + // GLEntryType code (Admin > System > Manage Codes), precisely so a manager here can never + // pick up money that was really meant for something else (a Demand Draft, say) just because + // it happens to sit in the same GL account. "General" and a missing tag (pre-existing rows + // from before this tag existed) are still adoptable -- only an explicit, different purpose + // blocks it. + final String depositEntryType = (String) dep.get("entry_type"); + if (depositEntryType != null && !"General".equals(depositEntryType) && !"Share Collection".equals(depositEntryType)) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose", + "Voucher " + voucherNo + " was recorded as " + depositEntryType + ", not a share collection. " + + "Choose a different voucher, or ask the cashier to correct the entry type."); + } + if (!jdbcTemplate.queryForList("SELECT id FROM m_sponsor_dd_issue_request WHERE cashier_deposit_request_id = ?", + toLong(dep.get("id"))).isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied", + "Voucher " + voucherNo + " has already been used to issue a Demand Draft."); + } // Prefer the member recorded on the deposit; fall back to the account being settled. final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint; @@ -657,7 +857,7 @@ public class ShareAdvanceCollectionApiResource { return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id" + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } @@ -679,16 +879,27 @@ public class ShareAdvanceCollectionApiResource { private Map adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) { final String glAccountNo = glAccountNoOverride.trim(); final List> requests = jdbcTemplate.queryForList( - "SELECT id, dr_amount, currency_code, office_id, credit_entries, clerk_id, clerk_name, submitted_at, actioned_at" - + " FROM m_clerk_disbursement_request" - + " WHERE voucher_no = ? AND TRIM(gl_account_no) = ? AND status = 'APPROVED' AND request_type = 'COLLECTION'" - + " ORDER BY id DESC", + "SELECT d.id, d.dr_amount, d.currency_code, d.office_id, d.credit_entries, d.clerk_id, d.clerk_name," + + " d.submitted_at, d.actioned_at, d.description, d.gl_transaction_id, cv.code_value AS entry_type" + + " FROM m_clerk_disbursement_request d" + + " LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id" + + " WHERE d.voucher_no = ? AND TRIM(d.gl_account_no) = ? AND d.status = 'APPROVED' AND d.request_type = 'COLLECTION'" + + " ORDER BY d.id DESC", voucherNo, glAccountNo); if (requests.isEmpty()) { return null; } final Map req = requests.get(0); + // Same purpose guard as the cashier deposit adoption above -- a COLLECTION credits a GL + // account the counter picked by hand, so it carries the identical ambiguity. + final String reqEntryType = (String) req.get("entry_type"); + if (reqEntryType != null && !"General".equals(reqEntryType) && !"Share Collection".equals(reqEntryType)) { + throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.wrong.purpose", + "Voucher " + voucherNo + " was recorded as " + reqEntryType + ", not a share collection. " + + "Choose a different voucher, or ask the clerk to correct the entry type."); + } + final Long savingsAccountId = debitSavingsAccountId((String) req.get("credit_entries")); if (savingsAccountId == null) { throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client", @@ -706,16 +917,20 @@ public class ShareAdvanceCollectionApiResource { : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final String receiptNo = nextReceiptNo(); + final String reqDescription = (String) req.get("description"); + final String note = reqDescription != null && !reqDescription.isBlank() ? reqDescription + : "Adopted from clerk disbursement voucher " + voucherNo; + final String glTxnId = req.get("gl_transaction_id") != null ? (String) req.get("gl_transaction_id") : voucherNo; jdbcTemplate.update( "INSERT INTO m_share_advance_collection " + "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, " + " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)", - receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, voucherNo, - toLong(req.get("clerk_id")), req.get("clerk_name"), "Adopted from clerk disbursement voucher " + voucherNo); + receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), req.get("dr_amount"), currency, glTxnId, + toLong(req.get("clerk_id")), req.get("clerk_name"), note); return jdbcTemplate.queryForMap( "SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name," - + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id" + + " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.collected_by_name, c.applied_share_account_id" + " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?", receiptNo); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 4da3c64d9..a250b0e45 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -116,6 +116,12 @@ public class ClerkDisbursementRequestApiResource { || !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER", "FUND_TRANSFER").contains(requestType)) { requestType = "DISBURSEMENT"; } + // What this GL leg is actually for — a code value under "GLEntryType" (Admin > System > + // Manage Codes). A COLLECTION credits a GL account the counter picks by hand, the same + // ambiguity a cashier Receipt has (see CashierDepositRequestApiResource and + // 0565_convert_entry_type_to_code_value.xml). ShareAdvanceCollectionApiResource checks this + // before adopting one of these rows as a share collection. + final Long entryTypeCvId = resolveEntryTypeCvId(fromJsonHelper.extractLongNamed("entryTypeCvId", root)); if (creditEntriesJson == null || creditEntriesJson.isBlank()) { throw apiError(400, "At least one entry is required"); @@ -208,8 +214,8 @@ public class ClerkDisbursementRequestApiResource { INSERT INTO m_clerk_disbursement_request (gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code, credit_entries, payment_type_id, payment_type_name, description, - status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type, entry_type_cv_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -218,7 +224,7 @@ public class ClerkDisbursementRequestApiResource { correctedCreditEntriesJson, paymentTypeId, paymentTypeName, description, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - activityLog, Timestamp.from(Instant.now()), requestType + activityLog, Timestamp.from(Instant.now()), requestType, entryTypeCvId ); // A collection pulls money into the GL, so it reads as a credit; a disbursement moves @@ -306,7 +312,7 @@ public class ClerkDisbursementRequestApiResource { + " credit_entries, payment_type_name, description," + " clerk_id, clerk_name, clerk_username," + " manager_id, manager_name, manager_username," - + " rejection_reason, txn_refs, error_message, submitted_at, actioned_at" + + " rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id" + " FROM m_clerk_disbursement_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -373,7 +379,7 @@ public class ClerkDisbursementRequestApiResource { final List> rows = jdbcTemplate.queryForList( "SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount," + " credit_entries, payment_type_name, description, clerk_name," - + " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at" + + " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at, entry_type_cv_id" + " FROM m_clerk_disbursement_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -497,6 +503,11 @@ public class ClerkDisbursementRequestApiResource { final List withdrawalSavingsTxnRefs = new ArrayList<>(); BigDecimal withdrawalSavingsTotal = BigDecimal.ZERO; Long collectionTargetGlId = null; + // The real acc_gl_journal_entry.transaction_id this approval posts -- captured here because + // nothing else on the row remembers it otherwise (txn_refs only holds the account-side + // transaction ids). Whoever needs the actual GL posting afterwards (Share Advance Collection + // adopting a tagged voucher, a GL detail view) reads it back from gl_transaction_id. + String approvedGlTxnId = null; // Savings-control GL data per account for the direct combined journal entry. // collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode). @@ -562,6 +573,7 @@ public class ClerkDisbursementRequestApiResource { linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2); linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1); } + approvedGlTxnId = btGlTxnId; // Audit log in m_bank_transfer_log final String btTxnRefsStr = String.join(",", txnRefs); @@ -707,6 +719,7 @@ public class ClerkDisbursementRequestApiResource { if (disbGlTxnId != null) { linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1); } + approvedGlTxnId = disbGlTxnId; } // ── Collection GL entry ─────────────────────────────────────────────── @@ -729,6 +742,7 @@ public class ClerkDisbursementRequestApiResource { if (collGlTxnId != null) { linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2); } + approvedGlTxnId = collGlTxnId; } else { final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); if (cashVaultGlId != null) { @@ -780,11 +794,11 @@ public class ClerkDisbursementRequestApiResource { jdbcTemplate.update(""" UPDATE m_clerk_disbursement_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?, - txn_refs = ?, activity_log = ?, actioned_at = ? + txn_refs = ?, gl_transaction_id = ?, activity_log = ?, actioned_at = ? WHERE id = ? """, currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id); + txnRefsStr, approvedGlTxnId, updatedLog, Timestamp.from(Instant.now()), id); final JsonObject res = new JsonObject(); res.addProperty("resourceId", id); @@ -1262,7 +1276,7 @@ public class ClerkDisbursementRequestApiResource { office_id, currency_code, credit_entries, payment_type_id, payment_type_name, description, clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, rejection_reason, - txn_refs, error_message, activity_log, submitted_at, actioned_at + txn_refs, error_message, activity_log, submitted_at, actioned_at, entry_type_cv_id FROM m_clerk_disbursement_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -1321,6 +1335,18 @@ public class ClerkDisbursementRequestApiResource { try { return new BigDecimal(raw); } catch (NumberFormatException e) { return null; } } + /** The counter's choice, when they made one — otherwise "General" under the GLEntryType code, + * resolved by name rather than a hardcoded id since Manage Codes owns these rows now. */ + private Long resolveEntryTypeCvId(final Long provided) { + if (provided != null) { + return provided; + } + return jdbcTemplate.queryForObject( + "SELECT cv.id FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id" + + " WHERE c.code_name = 'GLEntryType' AND cv.code_value = 'General'", + Long.class); + } + private String buildActivityLog(final String existingJson, final String action, final String username, final String note) { JsonArray log = new JsonArray(); 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 index 66d369a66..4145533a8 100644 --- 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 @@ -171,7 +171,7 @@ public class ShareAdditionRequestsApiResource { 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 + collected_by_name AS "collectedByName", status, note FROM m_share_advance_collection WHERE id IN (%s) ORDER BY id """.formatted(placeholders), ids.toArray()); } 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 448f14fc3..d07a28c4e 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 @@ -799,6 +799,15 @@ public class ShareAccountDataSerializer { advanceCollectionIds = extractAdvanceCollectionIds(element); if (advanceCollectionIds.isEmpty()) { baseDataValidator.reset().parameter(ShareAccountApiConstants.advancecollectionid_paramname).value(null).notNull(); + } else if (advanceCollectionIds.size() > 1) { + // Additional-share tranches fund from exactly one receipt for now -- combining + // instalments across several is the first-application Apply Collection dialog's own + // behaviour, not this one's, until this screen is asked to support it too. + baseDataValidator.reset().parameter(ShareAccountApiConstants.additionalcollectionids_paramname) + .value(advanceCollectionIds.size()) + .failWithCodeAndMessage("share.tranche.single.voucher.only", + "Only one voucher can currently fund an additional-share tranche. " + advanceCollectionIds.size() + + " were submitted."); } else { validateAdvanceCollectionsForTranche(advanceCollectionIds, account, sharesRequested, requestedDate, baseDataValidator); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java index aaa36da49..590378db0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDIssueRequestWritePlatformServiceImpl.java @@ -429,7 +429,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI + "(AC.TP = GL, target account = the DD Cash Suspense GL account, amount = " + expectedTotal + ")."); } final List> rows = this.jdbcTemplate.queryForList( - "SELECT id, status, gl_account_id, amount, txn_id FROM m_cashier_deposit_request WHERE id = ?", cashierDepositRequestId); + "SELECT d.id, d.status, d.gl_account_id, d.amount, d.txn_id, d.share_account_id, cv.code_value AS entry_type" + + " FROM m_cashier_deposit_request d LEFT JOIN m_code_value cv ON cv.id = d.entry_type_cv_id WHERE d.id = ?", + cashierDepositRequestId); if (rows.isEmpty()) { throw new IllegalArgumentException("Cashier deposit request not found: " + cashierDepositRequestId); } @@ -438,6 +440,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI throw new IllegalStateException( "The referenced cashier deposit request is not yet approved by a manager. Current status: " + deposit.get("status")); } + if (deposit.get("share_account_id") != null) { + throw new IllegalStateException("This cashier deposit request has already been used for a share application."); + } + // The counter tags what a GL receipt was actually collected for at save time -- a code value + // under GLEntryType (Admin > System > Manage Codes). See + // 0565_convert_entry_type_to_code_value.xml. "General" and a missing tag (rows predating the + // column) are still usable; only an explicit, different purpose blocks it here. + final Object entryType = deposit.get("entry_type"); + if (entryType != null && !"General".equals(entryType) && !"DD Issue".equals(entryType)) { + throw new IllegalStateException( + "This cashier deposit request was recorded as " + entryType + ", not a Demand Draft issuance. Ask the cashier to " + + "correct the entry type, or select a different deposit request."); + } final Object depositGlAccountObj = deposit.get("gl_account_id"); final Long depositGlAccountId = depositGlAccountObj != null ? ((Number) depositGlAccountObj).longValue() : null; if (!cashSuspenseGlAccountId.equals(depositGlAccountId)) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java index cf305c1e9..91a0947b6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sponsordd/service/SponsorDDReadPlatformServiceImpl.java @@ -626,10 +626,16 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, " + "cdr.dd_face_value, cdr.dd_charges, cdr.dd_gst_percentage, cdr.dd_gst_amount " + "from m_cashier_deposit_request cdr " + + "left join m_code_value cv on cv.id = cdr.entry_type_cv_id " + "where cdr.status = 'APPROVED' " + "and cdr.gl_account_id = (select cash_suspense_gl_account_id from m_sponsor_dd_gl_mapping limit 1) " + "and cdr.id not in (select cashier_deposit_request_id from m_sponsor_dd_issue_request " + "where cashier_deposit_request_id is not null) " + // Already spoken for by a share application, or tagged for something else at save + // time -- kept out of the picker rather than only rejected at submit, so the counter + // never even offers the wrong voucher. See 0565_convert_entry_type_to_code_value.xml. + + "and cdr.share_account_id is null " + + "and (cv.code_value IS NULL OR cv.code_value IN ('General', 'DD Issue')) " + "order by cdr.actioned_at desc"; return this.jdbcTemplate.query(sql, (rs, rowNum) -> new SponsorDDCashierDepositOptionData( rs.getLong("id"), 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 0e6164682..4c0994bd0 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 @@ -109,4 +109,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml new file mode 100644 index 000000000..57be7341c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0564_add_entry_type_to_gl_requests.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml new file mode 100644 index 000000000..67d708297 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0565_convert_entry_type_to_code_value.xml @@ -0,0 +1,124 @@ + + + + + + + + INSERT INTO m_code (code_name, is_system_defined) + SELECT 'GLEntryType', true + WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'GLEntryType'); + + + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT c.id, v.code_value, v.order_position, true, false + FROM m_code c + CROSS JOIN (VALUES + ('General', 1), + ('Share Collection', 2), + ('DD Issue', 3) + ) AS v(code_value, order_position) + WHERE c.code_name = 'GLEntryType' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv + WHERE cv.code_id = c.id AND cv.code_value = v.code_value + ); + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE m_cashier_deposit_request d + SET entry_type_cv_id = cv.id + FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id + WHERE c.code_name = 'GLEntryType' + AND cv.code_value = CASE d.entry_type + WHEN 'SHARE_COLLECTION' THEN 'Share Collection' + WHEN 'DD_ISSUE' THEN 'DD Issue' + ELSE 'General' + END + AND d.entry_type_cv_id IS NULL; + + UPDATE m_clerk_disbursement_request d + SET entry_type_cv_id = cv.id + FROM m_code_value cv JOIN m_code c ON c.id = cv.code_id + WHERE c.code_name = 'GLEntryType' + AND cv.code_value = CASE d.entry_type + WHEN 'SHARE_COLLECTION' THEN 'Share Collection' + WHEN 'DD_ISSUE' THEN 'DD Issue' + ELSE 'General' + END + AND d.entry_type_cv_id IS NULL; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml new file mode 100644 index 000000000..09500687a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + +