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

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:
2026-09-17 17:39:59 +05:30
co-authored by Claude Sonnet 5
parent b779d04571
commit 4c0512f154
11 changed files with 553 additions and 34 deletions
@@ -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();
@@ -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);
}
@@ -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();
@@ -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());
}
@@ -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);
}
@@ -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)) {
@@ -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>
@@ -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>
@@ -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>
@@ -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>