feat(shares, cashier): tag GL counter entries by purpose, close DD/Share voucher cross-use
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A cashier Receipt or clerk Collection into a GL account never named who or what it was for, so a manager applying a share request could adopt a voucher a cashier had actually collected for a Demand Draft, and vice versa. Adds a GLEntryType code (General/Share Collection/DD Issue, extensible via Manage Codes) that the counter sets at save time; Share Advance Collection and Sponsor DD issuance both check it before adopting a voucher, and also cross-check each other's own consumption tables directly so the guard holds even for untagged legacy rows. Also: - Share-Collection-tagged vouchers auto-surface on Apply Share / Additional Share screens instead of requiring a manual GL-account search, but only when a matching client_id already exists on the row or the request is scoped to a specific member's screen -- a clientless GL receipt is never auto-attributed to whoever's screen happened to be open. - gl_transaction_id column on m_clerk_disbursement_request captures the real GL posting a Collection approval makes, previously generated and discarded. - New GET .../gl-entries endpoint exposes a receipt's actual Dr/Cr legs for detail popups. - Additional-share tranches restricted to a single funding voucher for now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+24
-6
@@ -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();
|
||||
|
||||
+233
-18
@@ -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<Map<String, Object>> 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<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
|
||||
List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("transactionId", glTransactionId);
|
||||
final List<Map<String, Object>> entries = new java.util.ArrayList<>();
|
||||
for (final Map<String, Object> leg : legs) {
|
||||
final Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> adoptApprovedClerkDisbursementCollection(final String voucherNo, final String glAccountNoOverride) {
|
||||
final String glAccountNo = glAccountNoOverride.trim();
|
||||
final List<Map<String, Object>> 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<String, Object> 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);
|
||||
}
|
||||
|
||||
+34
-8
@@ -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<Map<String, Object>> 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<String> 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();
|
||||
|
||||
+1
-1
@@ -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());
|
||||
}
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+16
-1
@@ -429,7 +429,9 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
|
||||
+ "(AC.TP = GL, target account = the DD Cash Suspense GL account, amount = " + expectedTotal + ").");
|
||||
}
|
||||
final List<Map<String, Object>> 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)) {
|
||||
|
||||
+6
@@ -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"),
|
||||
|
||||
@@ -109,4 +109,7 @@
|
||||
<include file="parts/0553_add_voucher_no_to_dividend_claim_and_dd_issue_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0554_add_gl_account_to_sponsor_dd_issue_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0563_ledger_txn_sequence.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0564_add_entry_type_to_gl_requests.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0565_convert_entry_type_to_code_value.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0566_add_gl_transaction_id_to_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
A cashier "Receipt" and a clerk "Collection" both let the counter pick, by hand, any GL
|
||||
account to post cash into — deliberately, so a broken product mapping never blocks a genuine
|
||||
voucher (see ShareAdvanceCollectionApiResource). That freedom is also the hole: two totally
|
||||
unrelated flows (a Demand Draft cash intake, a share advance collection) can land in the same
|
||||
GL account with nothing on the row itself saying which one the money was actually collected
|
||||
for. A manager applying a share request has already picked up a voucher that was really meant
|
||||
to fund a DD, because nothing distinguished them.
|
||||
|
||||
entry_type is that distinguishing tag, set by the counter at save time and carried on the
|
||||
request row for its whole life. Every consumer that adopts one of these rows into its own
|
||||
flow (Share Advance Collection, Sponsor DD issuance) checks it before adopting, and it is
|
||||
exactly what lets a day-end reconciliation group GL-affecting counter activity by what it was
|
||||
actually for.
|
||||
|
||||
Defaults to GENERAL rather than being required, so every existing row and every caller that
|
||||
does not yet send it keeps working exactly as before.
|
||||
-->
|
||||
|
||||
<changeSet id="0564_add_entry_type_to_cashier_deposit_request" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_cashier_deposit_request" columnName="entry_type"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_cashier_deposit_request">
|
||||
<column name="entry_type" type="VARCHAR(30)" defaultValue="GENERAL">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0564_index_entry_type_cashier_deposit_request" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_cdr_entry_type"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_cashier_deposit_request" indexName="idx_cdr_entry_type">
|
||||
<column name="entry_type"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0564_add_entry_type_to_clerk_disbursement_request" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_clerk_disbursement_request" columnName="entry_type"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_disbursement_request">
|
||||
<column name="entry_type" type="VARCHAR(30)" defaultValue="GENERAL">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0564_index_entry_type_clerk_disbursement_request" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_disb_entry_type"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_clerk_disbursement_request" indexName="idx_disb_entry_type">
|
||||
<column name="entry_type"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
entry_type (0564_add_entry_type_to_gl_requests.xml) was a fixed three-value VARCHAR, edited
|
||||
only in code. Moved onto the standard m_code/m_code_value mechanism instead, so an admin can
|
||||
add another entry type later (Admin > System > Manage Codes) without a deployment, the same
|
||||
way every other extensible dropdown in this system already works.
|
||||
-->
|
||||
|
||||
<changeSet id="0565_create_gl_entry_type_code" author="CBS">
|
||||
<sql>
|
||||
INSERT INTO m_code (code_name, is_system_defined)
|
||||
SELECT 'GLEntryType', true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'GLEntryType');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0565_seed_gl_entry_type_values" author="CBS">
|
||||
<sql>
|
||||
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
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0565_add_entry_type_cv_id_cashier_deposit" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_cashier_deposit_request" columnName="entry_type_cv_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_cashier_deposit_request">
|
||||
<column name="entry_type_cv_id" type="INTEGER"/>
|
||||
</addColumn>
|
||||
<addForeignKeyConstraint baseTableName="m_cashier_deposit_request" baseColumnNames="entry_type_cv_id"
|
||||
constraintName="fk_cdr_entry_type_cv" referencedTableName="m_code_value" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0565_add_entry_type_cv_id_clerk_disbursement" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_clerk_disbursement_request" columnName="entry_type_cv_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_disbursement_request">
|
||||
<column name="entry_type_cv_id" type="INTEGER"/>
|
||||
</addColumn>
|
||||
<addForeignKeyConstraint baseTableName="m_clerk_disbursement_request" baseColumnNames="entry_type_cv_id"
|
||||
constraintName="fk_disb_entry_type_cv" referencedTableName="m_code_value" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<!-- Carries forward what 0564 already tagged, mapping the fixed old strings onto the new
|
||||
code_value rows one time. Any row still on GENERAL (the default) resolves to "General". -->
|
||||
<changeSet id="0565_backfill_entry_type_cv_id" author="CBS">
|
||||
<sql>
|
||||
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;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0565_index_entry_type_cv_cashier_deposit" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_cdr_entry_type_cv"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_cashier_deposit_request" indexName="idx_cdr_entry_type_cv">
|
||||
<column name="entry_type_cv_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0565_index_entry_type_cv_clerk_disbursement" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_disb_entry_type_cv"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_clerk_disbursement_request" indexName="idx_disb_entry_type_cv">
|
||||
<column name="entry_type_cv_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0565_drop_entry_type_varchar_cashier_deposit" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_cashier_deposit_request" columnName="entry_type"/>
|
||||
</preConditions>
|
||||
<dropIndex tableName="m_cashier_deposit_request" indexName="idx_cdr_entry_type"/>
|
||||
<dropColumn tableName="m_cashier_deposit_request" columnName="entry_type"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0565_drop_entry_type_varchar_clerk_disbursement" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_clerk_disbursement_request" columnName="entry_type"/>
|
||||
</preConditions>
|
||||
<dropIndex tableName="m_clerk_disbursement_request" indexName="idx_disb_entry_type"/>
|
||||
<dropColumn tableName="m_clerk_disbursement_request" columnName="entry_type"/>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
approve() in ClerkDisbursementRequestApiResource posts a real, balanced GL entry for both
|
||||
Disbursement and Collection mode (postCombinedJournalEntry) and gets back its own
|
||||
acc_gl_journal_entry.transaction_id, then only ever uses that id to link the entries back
|
||||
to the savings/current accounts involved, and lets it fall out of scope. Nothing on the row
|
||||
remembers it, so anyone needing the real posting afterwards (Share Advance Collection
|
||||
adopting a tagged voucher, an audit screen, a reversal) had nothing reliable to query by;
|
||||
txn_refs holds the account-side transaction ids, not the GL one.
|
||||
|
||||
gl_transaction_id is that missing reference, written once at approval time next to txn_refs.
|
||||
-->
|
||||
|
||||
<changeSet id="0566_add_gl_transaction_id_to_clerk_disbursement_request" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_clerk_disbursement_request" columnName="gl_transaction_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_disbursement_request">
|
||||
<column name="gl_transaction_id" type="VARCHAR(60)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0566_index_gl_transaction_id_clerk_disbursement" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_disb_gl_transaction_id"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_clerk_disbursement_request" indexName="idx_disb_gl_transaction_id">
|
||||
<column name="gl_transaction_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user