feat(accounting): GL-funded DD issuance, share-withdrawal GL settlement, and ledger txn-id hardening
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 Docker Builds / build (true, docker-compose.yml, mariadb) (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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (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 - PostgreSQL / test (test-core-2) (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-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

Share withdrawal (clerk):
- Add GL and NONE settlement modes alongside the existing SAVINGS mode. A clerk
  can now credit any GL account on the CR row of a Fund Transfer instead of the
  member's linked savings account; picking the product's own Sundry account
  self-cancels, so it downgrades to NONE and the request stands on leg 1 alone.
- Post Dr Sundry / Cr chosen GL for GL mode; raise a standalone Dr Share /
  Cr Sundry voucher for NONE mode, read back from the ledger.

Sponsor DD:
- Let a clerk fund a Demand Draft by debiting a GL account directly
  (payment_method = GL) under the existing maker-checker flow, reusing the
  shared postGlOffsetEntry() so only the debit-side GL resolution differs.

Cash reports and vouchers:
- Fix the Closing Cash Report total row and opening-balance carry-forward.
- Read the real till GL back from the underlying transaction when raising
  withdrawal, closure and loan vouchers, instead of labelling every leg
  "Cash in Hand".

Ledger transaction-id hardening:
A derived transaction_id text (e.g. "SH" + share transaction id) is not
guaranteed unique if the underlying numeric id is ever reused, which a manual
sequence reset can cause. Queries that read those rows back now disambiguate,
and flag flips can no longer touch an unrelated transaction:
- Read-back sites pick the most recently posted matching rows (ORDER BY id DESC
  + LIMIT) or, where the read happens long after posting, the candidate group
  whose created_date sits closest to the owning request's actioned_at.
- Every UPDATE ... SET reversed = true/false WHERE transaction_id = ? is now
  guarded by the opposite reversed state, so a collision can only flip rows
  that are currently live.
- Covers clerk disbursement, sponsor DD, cheque book, clearing, share tranche
  funding and share withdrawal.

Migrations 0551-0554: settlement GL account and voucher_no columns for the
clerk share withdrawal, dividend claim and sponsor DD issue request tables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 17:16:17 +05:30
co-authored by Claude Opus 5
parent 521d61d6b7
commit 184d1b8cf8
23 changed files with 1106 additions and 182 deletions
@@ -1056,13 +1056,32 @@ public class BranchDayRegisterApiResource {
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
final BigDecimal savingsDeposits = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
AND (office_id = ? OR office_id IS NULL)
""", BigDecimal.class, date, officeId));
// Cash paid over the counter to buy a demand draft is real cash arriving at the branch too --
// it was missing from this total entirely, understating expected cash by exactly what a
// walk-in DD purchaser (or a member paying by cash rather than an account debit) had handed
// over that day. face_value + charges + GST is the fee calculation, which can land on a paisa
// fraction (e.g. 18% GST on a 40 rupee charge is 7.20); a customer paying cash can only tender
// whole rupees, so CEIL to the rupee actually collected -- rounding down would silently leave
// this same gap, just one rupee smaller. ACCOUNT_DEBIT-funded DDs move no physical cash and
// are correctly excluded by the payment method filter.
final BigDecimal ddCashReceipts = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CEIL(dd.face_value + COALESCE(dd.charges_amount, 0) + COALESCE(dd.gst_amount, 0))), 0)
FROM m_sponsor_dd_issue_request dd
WHERE dd.status = 'APPROVED'
AND dd.payment_method = 'CASH'
AND dd.issued_date = ?
AND (dd.issuing_branch_id = ? OR dd.issuing_branch_id IS NULL)
""", BigDecimal.class, date, officeId));
final BigDecimal deposits = savingsDeposits.add(ddCashReceipts);
// A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED (deposits and
// disbursements use APPROVED; withdrawal is the one register that doesn't) -- filtering on
// APPROVED alone silently excluded every completed withdrawal from this total, overstating
@@ -624,7 +624,12 @@ public class CashierDepositRequestApiResource {
final Map<String, Object> gl = glAccountForVoucher(glAccountId);
credit = VoucherLine.creditGl(glAccountId, (String) gl.get("name"), (String) gl.get("gl_code"), amount);
} else {
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"), accountNo, amount);
// The request's own account_no is normally right, but if a cashier ever searched by a
// short/partial account number this is where that would surface -- prefer the number
// looked up fresh from savingsAccountId, the id actually posted against.
final String realAccountNo = savingsAccountNoOf((String) record.get("account_type"), savingsAccountId);
credit = VoucherLine.creditSavings(savingsAccountId, (String) record.get("client_name"),
realAccountNo != null ? realAccountNo : accountNo, amount);
}
voucherService.addLines(voucherNo, List.of(debit, credit));
@@ -634,6 +639,19 @@ public class CashierDepositRequestApiResource {
}
/** Name and code of a GL account, for printing on a voucher. Empty rather than absent if unmapped. */
/** "CURRENT" accounts live in their own {@code m_current_account} table with their own id space --
* always querying {@code m_savings_account} would silently return an unrelated savings account's
* number whenever the two id spaces happened to collide. Every other non-GL type here is a
* {@code m_savings_account} row. */
private String savingsAccountNoOf(final String accountType, final Long accountId) {
if (accountId == null) {
return null;
}
final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
.findFirst().orElse(null);
}
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
@@ -499,86 +499,52 @@ public class CashReportApiResource {
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
// Today's own movement -- what actually crossed the counter, the vault hatch or the
// settlement window on this one date. Unchanged from before: this is informational, "what
// happened today", and stays that way regardless of what the drawer carried in from an
// earlier, unsettled day.
final Map<Long, Map<String, Object>> todayLegs = fetchLegTotals("= CAST(? AS DATE)", txnDate, scopedCashierId);
// Every note and coin that reached the drawer or left it, per denomination.
//
// Four legs, not one. The counter breakdown alone answered "what did customers hand over",
// which is not the question a cashier closing a drawer asks: a teller given a ₹500 and a
// ₹100 from the vault, who then took a ₹100 deposit and paid a ₹100 withdrawal, saw only
// the ₹100 row — the ₹500 in their hand appeared nowhere, and the sheet could not be
// counted against. So the vault allocation and the settlement back are included on the same
// footing as the counter work, and each row says where its notes came from.
//
// Cashier scoping differs per leg because each table records the party differently: the
// counter tables name the acting user, the cash tables name the cashier row.
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
// What the drawer already held before today started -- everything ever recorded up to (but
// not including) this date, net. A cashier who force-closed yesterday without settling, then
// settled today, hands back notes that arrived on several different days; comparing that
// settlement only against today's own vault-in/deposit legs manufactured an impossible
// negative count for every denomination the earlier days had contributed. Cumulative "as of
// yesterday" is exactly the figure VaultPositionService/buildPositionForCashier already use
// for a drawer's running position, applied here as this report's opening balance.
final Map<Long, Map<String, Object>> openingLegs = fetchLegTotals("< CAST(? AS DATE)", txnDate, scopedCashierId);
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ "FROM ("
// Counter work: what customers handed over and were paid.
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ " FROM m_savings_txn_denomination std "
+ " WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
+ " UNION ALL "
// Drawn from the vault in the morning.
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ " WHERE ct.transfer_date = CAST(? AS DATE) AND COALESCE(ct.is_reversed, false) = false "
+ vaultCashierFilter
+ " UNION ALL "
// Handed back to the vault at close.
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ " WHERE cs.settlement_date = CAST(? AS DATE) AND COALESCE(cs.is_reversed, false) = false "
+ settleCashierFilter
+ ") legs "
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ "ORDER BY legs.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
final Map<Long, Map<String, Object>> merged = new LinkedHashMap<>(todayLegs);
for (final Long denomId : openingLegs.keySet()) {
merged.computeIfAbsent(denomId, id -> emptyLegRow(openingLegs.get(id)));
}
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
final long vaultInCount = rs.getLong("vault_in_count");
final long depositCount = rs.getLong("deposit_count");
final long withdrawalCount = rs.getLong("withdrawal_count");
final long settledOutCount = rs.getLong("settled_out_count");
final BigDecimal vaultInAmount = rs.getBigDecimal("vault_in_amount");
final BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
final BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
final BigDecimal settledOutAmount = rs.getBigDecimal("settled_out_amount");
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> today : merged.values()) {
final Long denomId = (Long) today.get("denominationId");
final Map<String, Object> opening = openingLegs.getOrDefault(denomId, null);
final long openingCount = opening == null ? 0L
: (long) opening.get("vaultInCount") + (long) opening.get("depositCount")
- (long) opening.get("withdrawalCount") - (long) opening.get("settledOutCount");
final BigDecimal openingAmount = opening == null ? BigDecimal.ZERO
: ((BigDecimal) opening.get("vaultInAmount")).add((BigDecimal) opening.get("depositAmount"))
.subtract((BigDecimal) opening.get("withdrawalAmount")).subtract((BigDecimal) opening.get("settledOutAmount"));
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
final long vaultInCount = (long) today.get("vaultInCount");
final long depositCount = (long) today.get("depositCount");
final long withdrawalCount = (long) today.get("withdrawalCount");
final long settledOutCount = (long) today.get("settledOutCount");
final BigDecimal vaultInAmount = (BigDecimal) today.get("vaultInAmount");
final BigDecimal depositAmount = (BigDecimal) today.get("depositAmount");
final BigDecimal withdrawalAmount = (BigDecimal) today.get("withdrawalAmount");
final BigDecimal settledOutAmount = (BigDecimal) today.get("settledOutAmount");
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", denomId);
row.put("denominationValue", today.get("denominationValue"));
row.put("kind", today.get("kind"));
row.put("openingCount", openingCount);
row.put("openingAmount", openingAmount);
row.put("vaultInCount", vaultInCount);
row.put("vaultInAmount", vaultInAmount);
row.put("depositCount", depositCount);
@@ -587,13 +553,18 @@ public class CashReportApiResource {
row.put("withdrawalAmount", withdrawalAmount);
row.put("settledOutCount", settledOutCount);
row.put("settledOutAmount", settledOutAmount);
// What is left of this denomination: everything that came in, less everything that went
// out. This is the figure a teller counts their notes against, per note.
row.put("netCount", vaultInCount + depositCount - withdrawalCount - settledOutCount);
row.put("netAmount",
vaultInAmount.add(depositAmount).subtract(withdrawalAmount).subtract(settledOutAmount));
return row;
}, args.toArray());
// What is left of this denomination: whatever the drawer already held, plus what came in
// today, less what went out today. This is the figure a teller counts their notes
// against, per note.
row.put("netCount", openingCount + vaultInCount + depositCount - withdrawalCount - settledOutCount);
row.put("netAmount", openingAmount.add(vaultInAmount).add(depositAmount).subtract(withdrawalAmount)
.subtract(settledOutAmount));
rows.add(row);
}
rows.sort((a, b) -> {
final int byValue = ((Integer) b.get("denominationValue")).compareTo((Integer) a.get("denominationValue"));
return byValue != 0 ? byValue : String.valueOf(a.get("kind")).compareTo(String.valueOf(b.get("kind")));
});
BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO;
@@ -762,6 +733,105 @@ public class CashReportApiResource {
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
* report and the position screen can never disagree about the same drawer.
*/
/**
* The four legs of {@link #closing} -- counter work, vault-in and settled-out -- summed per
* denomination, with {@code dateComparison} deciding which side of the target date each leg is
* read from ({@code "= CAST(? AS DATE)"} for the day itself, {@code "< CAST(? AS DATE)"} for
* everything before it). One query shape shared by both calls in {@link #closing}, rather than
* two copies that could drift apart.
*/
private Map<Long, Map<String, Object>> fetchLegTotals(final String dateComparison, final String dateBound,
final Long scopedCashierId) {
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
final String vaultCashierFilter = scopedCashierId != null ? "AND ct.to_cashier_id = ? " : "";
final String settleCashierFilter = scopedCashierId != null ? "AND cs.from_cashier_id = ? " : "";
final String sql = "SELECT legs.denomination_id, legs.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.count ELSE 0 END) AS vault_in_count, "
+ "SUM(CASE WHEN legs.leg = 'VAULT_IN' THEN legs.total_amount ELSE 0 END) AS vault_in_amount, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN legs.leg = 'DEPOSIT' THEN legs.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN legs.leg = 'WITHDRAWAL' THEN legs.total_amount ELSE 0 END) AS withdrawal_amount, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.count ELSE 0 END) AS settled_out_count, "
+ "SUM(CASE WHEN legs.leg = 'SETTLED_OUT' THEN legs.total_amount ELSE 0 END) AS settled_out_amount "
+ "FROM ("
+ " SELECT std.denomination_id, std.denomination_value, std.count, std.total_amount, "
+ " CASE WHEN std.txn_type = 'DEPOSIT' THEN 'DEPOSIT' ELSE 'WITHDRAWAL' END AS leg "
+ " FROM m_savings_txn_denomination std "
+ " WHERE std.txn_date " + dateComparison + " " + cashierFilter + COUNTER_NOT_REVERSED
+ " UNION ALL "
+ " SELECT ctd.denomination_id, ctd.denomination_value, ctd.count, ctd.total_amount, 'VAULT_IN' AS leg "
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ " WHERE ct.transfer_date " + dateComparison + " AND COALESCE(ct.is_reversed, false) = false "
+ vaultCashierFilter
+ " UNION ALL "
+ " SELECT csd.denomination_id, csd.denomination_value, csd.count, csd.total_amount, 'SETTLED_OUT' AS leg "
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ " WHERE cs.settlement_date " + dateComparison + " AND COALESCE(cs.is_reversed, false) = false "
+ settleCashierFilter
+ ") legs "
+ "LEFT JOIN m_denomination_master dm ON dm.id = legs.denomination_id "
+ "GROUP BY legs.denomination_id, legs.denomination_value, dm.kind "
+ "ORDER BY legs.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
args.add(dateBound);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
final Map<Long, Map<String, Object>> byDenomination = new LinkedHashMap<>();
jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> row = new LinkedHashMap<>();
final Long denomId = rs.getLong("denomination_id");
row.put("denominationId", denomId);
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
row.put("vaultInCount", rs.getLong("vault_in_count"));
row.put("vaultInAmount", rs.getBigDecimal("vault_in_amount"));
row.put("depositCount", rs.getLong("deposit_count"));
row.put("depositAmount", rs.getBigDecimal("deposit_amount"));
row.put("withdrawalCount", rs.getLong("withdrawal_count"));
row.put("withdrawalAmount", rs.getBigDecimal("withdrawal_amount"));
row.put("settledOutCount", rs.getLong("settled_out_count"));
row.put("settledOutAmount", rs.getBigDecimal("settled_out_amount"));
byDenomination.put(denomId, row);
return row;
}, args.toArray());
return byDenomination;
}
/** A zero-activity "today" row shaped like {@link #fetchLegTotals}'s own rows, for a denomination
* that carried an opening balance into this date but saw no movement of its own on it -- without
* this, that denomination would vanish from the report instead of showing its carried-forward
* count untouched. */
private Map<String, Object> emptyLegRow(final Map<String, Object> template) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", template.get("denominationId"));
row.put("denominationValue", template.get("denominationValue"));
row.put("kind", template.get("kind"));
row.put("vaultInCount", 0L);
row.put("vaultInAmount", BigDecimal.ZERO);
row.put("depositCount", 0L);
row.put("depositAmount", BigDecimal.ZERO);
row.put("withdrawalCount", 0L);
row.put("withdrawalAmount", BigDecimal.ZERO);
row.put("settledOutCount", 0L);
row.put("settledOutAmount", BigDecimal.ZERO);
return row;
}
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
final Object total = position.get("totalAmount");
@@ -389,8 +389,12 @@ public class ChequeBookIssuanceChargeService {
/** Links the DEBIT row (type_enum=2) of this journal transaction to the customer's account so it shows an Account No. */
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
final List<Long> rowIds = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
@@ -192,8 +192,12 @@ public class ClearingJournalService {
}
private void linkDebitEntryToAccount(final String glTxnId, final Long accountId) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
final List<Long> rowIds = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 ORDER BY id ASC LIMIT 1", Long.class,
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " ORDER BY id DESC LIMIT 1", Long.class,
glTxnId);
if (!rowIds.isEmpty()) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?", accountId,
@@ -145,6 +145,24 @@ public class ClerkDisbursementRequestApiResource {
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
throw apiError(400, "Each entry must have an account number");
}
// Whatever the counter searched with to find this account (often a short suffix, not the
// full number) is not what belongs on this record permanently — replace it here, once, at
// submit time, with the real number looked up from the id the search actually resolved.
// Leaves the entry's own accountNo untouched only when it carries no id to look up from
// (a GL leg's account/glAccountId is set, so this only bites when the search itself never
// matched anything, which submit-time validation elsewhere is already responsible for).
final String entryAccountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
final boolean entryIsGl = "GL".equals(entryAccountType);
final String realAccountNo = entryIsGl
? (entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
? glCodeOf(entry.get("glAccountId").getAsLong())
: null)
: (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
? nonGlAccountNoOf(entryAccountType, entry.get("savingsAccountId").getAsLong())
: null);
if (realAccountNo != null) {
entry.addProperty("accountNo", realAccountNo);
}
// Checked by the direction of the leg. A Dr leg takes money out of the account and is
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
// working, since nothing about a restricted customer should prevent them being paid.
@@ -180,6 +198,12 @@ public class ClerkDisbursementRequestApiResource {
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
// Same correction as each entry above, for the request's own top-level GL — and the entries
// are stored from the mutated array (each accountNo now the real one), not the original
// as-submitted JSON string.
final String realGlAccountNo = Optional.ofNullable(glCodeOf(glAccountId)).orElse(glAccountNo);
final String correctedCreditEntriesJson = gson.toJson(creditEntries);
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_clerk_disbursement_request
(gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code,
@@ -189,9 +213,9 @@ public class ClerkDisbursementRequestApiResource {
RETURNING id
""",
Long.class,
glAccountId, glAccountNo, glAccountName, txnAmount,
glAccountId, realGlAccountNo, glAccountName, txnAmount,
officeId != null ? officeId : currentUser.getOffice().getId(), currencyCode,
creditEntriesJson, paymentTypeId, paymentTypeName, description,
correctedCreditEntriesJson, paymentTypeId, paymentTypeName, description,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
activityLog, Timestamp.from(Instant.now()), requestType
@@ -1107,6 +1131,34 @@ public class ClerkDisbursementRequestApiResource {
* A failure here does not fail the approval: every transaction is posted by this point, and the
* money has moved. The problem is logged, and the voucher keeps its number without sides.
*/
/** The GL account's own code, looked up fresh from its id -- never trust a caller-supplied string
* for what belongs on a voucher's GL-side line. Null-safe so a possibly-missing id passes through. */
private String glCodeOf(final Long glAccountId) {
if (glAccountId == null) {
return null;
}
return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
.findFirst().orElse(null);
}
/** The account's own account number, looked up fresh from its id -- same reasoning as
* {@link #glCodeOf}, for the non-GL side of a voucher line. The id travels in this entry's
* {@code savingsAccountId} field regardless of what kind of account it actually names (this
* request's JSON has no separate field per account type), but "CURRENT" accounts live in their
* own {@code m_current_account} table with their own id space -- looking every non-GL id up
* against {@code m_savings_account} would silently return an unrelated savings account's number
* whenever the two id spaces happened to collide, which is worse than the truncated-search-string
* bug this method exists to fix. Every other non-GL type (savings, fixed deposit, recurring
* deposit) is a {@code m_savings_account} row distinguished only by {@code deposit_type_enum}. */
private String nonGlAccountNoOf(final String accountType, final Long accountId) {
if (accountId == null) {
return null;
}
final String table = "CURRENT".equalsIgnoreCase(accountType) ? "m_current_account" : "m_savings_account";
return jdbcTemplate.queryForList("SELECT account_no FROM " + table + " WHERE id = ?", String.class, accountId).stream()
.findFirst().orElse(null);
}
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
final String requestType) {
@@ -1124,12 +1176,28 @@ public class ClerkDisbursementRequestApiResource {
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
final boolean isGlEntry = "GL".equals(accountType);
// For a Disbursement/Collection, the society's own GL side is added once as a single
// total further down (from sourceGlId, the actually-resolved id) -- not read per entry
// here. A Collection's payload names its GL leg inside creditEntries too (so the
// amounts balance for the request's own DR/CR validation at submit time), but if this
// loop also turned that same entry into a line, the GL account would be posted twice on
// one voucher: once here (wrongly signed, since entryIsDebit below ignores this entry's
// own dbCr and applies isCollection to every entry in the array) and again as the
// society's side, with counterpartyTotal inflated by the GL entry's own amount on top of
// it. A bank transfer has no separate society side and every entry, GL or not, is a real
// leg of the transfer, so nothing is skipped there.
if (!isBankTransfer && isGlEntry) {
continue;
}
final BigDecimal entryAmount = new BigDecimal(entry.get("amount").getAsString());
final String accountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
final String enteredAccountNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : null;
final String accountName = entry.has("accountName") && !entry.get("accountName").isJsonNull()
? entry.get("accountName").getAsString()
: accountNo;
final boolean isGlEntry = "GL".equals(entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
: enteredAccountNo;
final Long glId = isGlEntry && entry.has("glAccountId") && !entry.get("glAccountId").isJsonNull()
? entry.get("glAccountId").getAsLong()
@@ -1138,6 +1206,14 @@ public class ClerkDisbursementRequestApiResource {
? entry.get("savingsAccountId").getAsLong()
: null;
// Whatever the counter searched with to find this account (often a short suffix, not
// the full number) is never what belongs on the slip. The id is what was actually
// resolved and posted against, so look its real number up from the database rather
// than trust the string the client happened to submit alongside it -- the id is the
// only part of this entry that can't be typo'd or truncated.
final String accountNo = isGlEntry ? Optional.ofNullable(glCodeOf(glId)).orElse(enteredAccountNo)
: Optional.ofNullable(nonGlAccountNoOf(accountType, savingsId)).orElse(enteredAccountNo);
// A bank transfer states each side on the entry itself; the other two directions
// put every counterparty on the side opposite the society's GL.
final boolean entryIsDebit = isBankTransfer
@@ -1163,7 +1239,10 @@ public class ClerkDisbursementRequestApiResource {
// between them — so its entries already balance on their own.
if (!isBankTransfer) {
final String glName = (String) record.get("gl_account_name");
final String glNo = (String) record.get("gl_account_no");
// Same reasoning as above: gl_account_no is whatever string was stored on this request
// at submit time, not necessarily this GL account's real code. sourceGlId is what was
// actually resolved and posted against.
final String glNo = Optional.ofNullable(glCodeOf(sourceGlId)).orElse((String) record.get("gl_account_no"));
lines.add(isCollection ? VoucherLine.creditGl(sourceGlId, glName, glNo, counterpartyTotal)
: VoucherLine.debitGl(sourceGlId, glName, glNo, counterpartyTotal));
}
@@ -1321,9 +1400,12 @@ public class ClerkDisbursementRequestApiResource {
private Long findCashVaultGlId(final List<String> txnRefs, final int typeEnum) {
for (final String txnRef : txnRefs) {
// ORDER BY id DESC: if transaction_id text is ever reused (e.g. after a sequence
// reset), always prefer the most recently posted matching row over a stale one.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
+ " WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT 1",
"S" + txnRef, typeEnum);
if (!rows.isEmpty()) {
return ((Number) rows.get(0).get("account_id")).longValue();
@@ -1402,9 +1484,12 @@ public class ClerkDisbursementRequestApiResource {
* typeEnum=2 (DEBIT) → SavingsControl for withdrawals (DR SavingsControl / CR CashVault).
*/
private Long findAccountGlFromTxn(final String fullTxnId, final int typeEnum) {
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text,
// in case the id is ever reused (e.g. after a sequence reset).
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
+ " WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT 1",
fullTxnId, typeEnum);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue();
}
@@ -1417,8 +1502,11 @@ public class ClerkDisbursementRequestApiResource {
private void markTxnGlEntriesReversed(final List<GlEntry> entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
// Guarded by reversed = false: if transaction_id text is ever reused, this can
// only flip rows that are currently live, never re-touch an already-reversed
// unrelated transaction's rows.
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?",
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
e.txnId);
}
}
@@ -1429,7 +1517,7 @@ public class ClerkDisbursementRequestApiResource {
for (final GlEntry e : entries) {
if (e.txnId != null) {
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ?",
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ? AND reversed = true",
e.txnId);
}
}
@@ -1442,9 +1530,15 @@ public class ClerkDisbursementRequestApiResource {
*/
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
final List<GlEntry> entries, final int typeEnum) {
// Take only the most recently posted `entries.size()` rows for this transaction_id text,
// then restore ascending id order. If the id text is ever reused (e.g. after a sequence
// reset), this keeps the positional zip below scoped to the rows this call itself just
// posted, instead of silently pulling in an older, unrelated combined entry's rows.
final List<Long> rowIds = jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? ORDER BY id ASC",
Long.class, glTxnId, typeEnum);
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT ?",
Long.class, glTxnId, typeEnum, entries.size());
java.util.Collections.reverse(rowIds);
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
final Long savingsAccId = entries.get(i).savingsAccountId;
if (savingsAccId != null) {
@@ -797,20 +797,31 @@ public class ClerkClosureRequestApiResource {
}
try {
final Map<String, Object> control = voucherService.savingsControlAccount(savingsAccountId);
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
final String memberSideName = record.get("client_name") != null ? String.valueOf(record.get("client_name"))
: (String) control.get("name");
final String accountNo = (String) record.get("account_no");
final List<VoucherLine> lines = new ArrayList<>();
lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount));
// This is the member's own savings account leg, not a GL posting -- debitGl(controlGlId,
// ...) named the shared Savings Control GL account here, mismatched against the member's
// own account number on the same line (the same bug class as the share-withdrawal voucher).
lines.add(VoucherLine.debitSavings(savingsAccountId, memberSideName, accountNo, closingAmount));
final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO);
if (roundingDirection > 0) {
lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
lines.add(VoucherLine.debitGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs()));
}
lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount));
// The same Cash at Vault / Cash at Teller account postRoundingAdjustment() actually posts
// to (resolveCashGlAccount()) — named and coded from the ledger rather than the fixed
// "Cash in Hand" / no-code placeholder this used to show, which said nothing when a reader
// wanted to confirm this voucher against the real entry.
final Map<String, Object> cashGlForVoucher = resolveCashGlAccount();
final Long cashGlIdForVoucher = ((Number) cashGlForVoucher.get("id")).longValue();
lines.add(VoucherLine.creditGl(cashGlIdForVoucher, (String) cashGlForVoucher.get("name"), glCodeOf(cashGlIdForVoucher),
cashierDisbursedAmount));
if (roundingDirection < 0) {
lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs()));
final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId);
lines.add(VoucherLine.creditGl(roundOffGlId, glNameOf(roundOffGlId), glCodeOf(roundOffGlId), roundingAmount.abs()));
}
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
@@ -893,6 +904,28 @@ public class ClerkClosureRequestApiResource {
* Falls back to that code where the product has no mapping yet — every product created before
* the row existed is in that position, and a closure must not fail over a few paise.
*/
/** The GL account's own code, for a voucher's GL-side line — never the account/party this entry
* happens to relate to. Null-safe so a possibly-missing id passes through. */
private String glCodeOf(final Long glAccountId) {
if (glAccountId == null) {
return null;
}
return jdbcTemplate.queryForList("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
.findFirst().orElse(null);
}
/** The GL account's own configured name, for the party label on a voucher's GL-side line — fixed
* labels like "Round Off Account" stood in for whatever the product/tenant's own chart of
* accounts actually maps the rounding/cash GL to, so a voucher read as posting against a party
* that doesn't exist in the ledger whenever the real mapped account was named differently. */
private String glNameOf(final Long glAccountId) {
if (glAccountId == null) {
return "GL Account";
}
return jdbcTemplate.queryForList("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId).stream()
.findFirst().orElse("GL Account");
}
private Long resolveRoundOffGlAccount(final Long savingsAccountId) {
if (savingsAccountId != null) {
final List<Long> mapped = jdbcTemplate.queryForList("""
@@ -72,9 +72,10 @@ import org.springframework.transaction.annotation.Transactional;
* from the Clerk Dashboard. A clerk (maker) submits a request naming the shares to redeem plus the
* governing Board Resolution Number / Date of Meeting; a Bank Manager (checker) approves it, which
* server-side executes the real Fineract "redeemshares" command against the share account, or
* rejects it. Mirrors {@link ClerkWithdrawalRequestApiResource} (cash withdrawal) — same
* maker-checker shape, adapted for shares (no cash denomination/disbursement leg; a share
* redemption is a book-entry, not a cash handover).
* rejects it. Settlement is always a SAVINGS book-transfer to the member's linked savings account —
* approval alone is terminal, see {@link #creditLinkedSavingsAccount} — never a cash payout at the
* teller counter (no cash denomination/disbursement leg; a share redemption is a book-entry, not a
* cash handover).
*/
@Path("/v1/clerk/share-withdrawal-requests")
@Component
@@ -100,7 +101,8 @@ public class ClerkShareWithdrawalRequestApiResource {
"clerk_username", "manager_id", "manager_name", "manager_username", "rejection_reason", "share_transaction_id",
"activity_log", "submitted_at", "actioned_at", "settlement_payment_type_id",
"(SELECT value FROM m_payment_type WHERE id = settlement_payment_type_id) AS settlement_payment_type_name",
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot" };
"settlement_amount", "settlement_savings_transaction_id", "settlement_row_snapshot", "settlement_mode",
"settlement_gl_account_id" };
// "Share Redemption Transfer" (a dedicated, teller-hidden payment type) was retired in favour of
// the plain "Transfer" type every settlement now uses -- this fallback code name follows it, so a
@@ -142,6 +144,16 @@ public class ClerkShareWithdrawalRequestApiResource {
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
final String description = fromJsonHelper.extractStringNamed("description", root);
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
// 'SAVINGS' (default, and what every request submitted before this field existed carries)
// settles by book-transfer to the linked savings account; 'GL' settles by book-transfer to
// whichever GL account the clerk picked on the CR row instead (a Sundry-style settlement that
// never touches a member's own account) — see postGlSettlement() in approve(). 'NONE' is not
// accepted from the client at all; it is what 'GL' downgrades to below when the clerk's own
// chosen GL account turns out to be the redemption's own Sundry account.
final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
: settlementModeRaw.trim().toUpperCase();
Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root);
// Full snapshot of the CR/settlement row as the clerk filled it in (account no., ECS a/c no.,
// amount, cheque no./date, token no., description, bank name, routing code, payment type) —
// stored verbatim as submitted JSON purely for audit purposes; business logic never re-reads
@@ -169,8 +181,31 @@ public class ClerkShareWithdrawalRequestApiResource {
if (meetingDate == null || meetingDate.isBlank()) {
throw apiError(400, "Date of Meeting is required for a share withdrawal");
}
if (!"SAVINGS".equals(settlementMode) && !"GL".equals(settlementMode)) {
throw apiError(400, "Settlement Mode must be 'SAVINGS' or 'GL'");
}
if ("GL".equals(settlementMode)) {
if (settlementGlAccountId == null) {
throw apiError(400, "Settlement GL Account is required for a GL settlement");
}
GLAccountTransactionGuard.assertPostable(jdbcTemplate, settlementGlAccountId);
// The redemption's own leg 1 (routeRedeemIntoSundry(), unconditional) always credits the
// product's own Sundry GL -- picking that same account as the settlement target here
// would make postGlSettlement() debit and credit the identical account, a self-cancelling
// entry with no real movement that no real CBS ledger allows. Rather than reject this
// outright, treat it as what the clerk actually means: "stop here, the value stays in
// Sundry" -- downgrade to 'NONE' so approve() posts nothing further and raises a voucher
// for exactly leg 1 (Dr Share / Cr Sundry) on its own, standalone.
final Long productIdForSundryCheck = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?",
Long.class, shareAccountId);
final Long glSundryForCheck = resolveGl(productIdForSundryCheck, FA_SHARES_SUSPENSE);
if (glSundryForCheck != null && glSundryForCheck.equals(settlementGlAccountId)) {
settlementMode = "NONE";
settlementGlAccountId = null;
}
}
if (settlementPaymentTypeId == null) {
throw apiError(400, "Settlement Payment Type is required — pick how the linked savings account should be credited");
throw apiError(400, "Settlement Payment Type is required — pick how the settlement should be credited");
}
validateSettlementPaymentType(settlementPaymentTypeId);
@@ -180,21 +215,23 @@ public class ClerkShareWithdrawalRequestApiResource {
INSERT INTO m_clerk_share_withdrawal_request
(share_account_id, account_no, client_id, client_name, requested_shares, requested_date,
resolution_number, meeting_date, description, status, clerk_id, clerk_name, clerk_username,
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
activity_log, submitted_at, settlement_payment_type_id, settlement_row_snapshot,
settlement_mode, settlement_gl_account_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class, shareAccountId, accountNo, clientId, clientName, requestedShares, Date.valueOf(requestedDate),
resolutionNumber, Date.valueOf(meetingDate), description, "PENDING", currentUser.getId(), currentUser.getDisplayName(),
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot);
currentUser.getUsername(), activityLog, Timestamp.from(Instant.now()), settlementPaymentTypeId, settlementRowSnapshot,
settlementMode, settlementGlAccountId);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
/**
* Settlement is always a book-transfer from Share Sundry to the linked savings account — never a
* cash payout at the teller counter — so cash-flagged payment types are rejected here regardless
* of what the clerk picked in the UI.
* A SAVINGS or GL settlement is always a book-transfer — never a cash payout at the teller
* counter — so cash-flagged payment types are rejected here regardless of what the clerk picked
* in the UI.
*/
private void validateSettlementPaymentType(final Long paymentTypeId) {
final Map<String, Object> paymentType = jdbcTemplate
@@ -400,9 +437,15 @@ public class ClerkShareWithdrawalRequestApiResource {
savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue()
: null;
}
checks.add(checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
savingsAccountId == null ? "No linked savings account configured"
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
// A GL settlement never touches a savings account, so this check is reported as
// not-applicable rather than failed for a GL request — a share account with no linked
// savings is a perfectly valid GL-settlement case.
final boolean glSettlementMode = "GL".equals(record.get("settlement_mode"));
checks.add(glSettlementMode
? checkResult("Linked savings account is Active", true, "Not applicable — settlement is a GL book-transfer")
: checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300,
savingsAccountId == null ? "No linked savings account configured"
: "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus)));
// Loan / Tenure / Guarantor checks — this bank links shares to a loan (m_share_account.linked_loan_id)
// when shares secure a loan against shares, so a withdrawal must not weaken that security while the
@@ -478,19 +521,31 @@ public class ClerkShareWithdrawalRequestApiResource {
}
checks.add(checkResult("Settlement Payment Type is valid (non-cash)", paymentTypeNonCash, paymentTypeDetail));
boolean glMappingOk = false;
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
if (savingsProductId != null && settlementPaymentTypeId != null) {
final Map<String, Object> mapping = jdbcTemplate.queryForList("""
SELECT gl.name AS glName FROM acc_product_mapping apm
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
""", savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
glMappingOk = mapping != null;
glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
: "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
// Only meaningful for a SAVINGS settlement: creditLinkedSavingsAccount() posts through a
// plain savings deposit command, which resolves its GL leg from the savings product's own
// Fund Source mapping -- the exact bug class this check exists to catch. A GL settlement
// never goes near that mapping at all; postGlSettlement() resolves Sundry and the clerk's
// chosen GL account directly, so this check is reported not-applicable instead.
if (glSettlementMode) {
final Object glAccountIdObj = record.get("settlement_gl_account_id");
final boolean settlementGlPresent = glAccountIdObj != null;
checks.add(checkResult("Settlement GL Account recorded", settlementGlPresent,
settlementGlPresent ? glCodeOf(((Number) glAccountIdObj).longValue()) : "Missing"));
} else {
boolean glMappingOk = false;
String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check";
if (savingsProductId != null && settlementPaymentTypeId != null) {
final Map<String, Object> mapping = jdbcTemplate.queryForList("""
SELECT gl.name AS glName FROM acc_product_mapping apm
JOIN acc_gl_account gl ON gl.id = apm.gl_account_id
WHERE apm.product_type = 2 AND apm.product_id = ? AND apm.payment_type = ? AND apm.financial_account_type = 1
""", savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null);
glMappingOk = mapping != null;
glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName")
: "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry";
}
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
}
checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail));
if (clientId != null) {
final Map<String, Object> kycRow = jdbcTemplate.queryForList("""
@@ -680,12 +735,34 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long settlementPaymentTypeId = storedSettlementPaymentTypeId instanceof Number
? ((Number) storedSettlementPaymentTypeId).longValue()
: null;
final Settlement settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(),
resolutionNumber, settlementPaymentTypeId);
final String settlementNote = settlement != null
? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
: "No linked savings account on this share account — settlement must be completed manually.";
final String settlementMode = Optional.ofNullable((String) record.get("settlement_mode")).orElse("SAVINGS");
final Settlement settlement;
final GlSettlement glSettlement;
final String settlementNote;
if ("NONE".equals(settlementMode)) {
// The clerk deliberately chose to stop at leg 1 -- the redemption value stays in Share
// Sundry (already credited by routeRedeemIntoSundry() above), with no second leg routing
// it anywhere else. raiseSundryOnlyVoucher() below gives that leg its own standalone
// voucher instead of leaving it visible only as "Also posted" under some other voucher.
settlement = null;
glSettlement = null;
settlementNote = "Redemption booked to Share Sundry — no further settlement leg.";
} else if ("GL".equals(settlementMode)) {
settlement = null;
final Object glAccountIdObj = record.get("settlement_gl_account_id");
final Long settlementGlAccountId = glAccountIdObj instanceof Number ? ((Number) glAccountIdObj).longValue() : null;
glSettlement = postGlSettlement(shareAccountId, shareTransactionId, requestedDateObj.toString(), settlementGlAccountId);
settlementNote = glSettlement != null
? "Settled " + glSettlement.amount() + " to GL account " + glCodeOf(glSettlement.glAccountId())
: "Share transaction not found — GL settlement could not be booked.";
} else {
glSettlement = null;
settlement = creditLinkedSavingsAccount(shareAccountId, shareTransactionId, requestedDateObj.toString(), resolutionNumber,
settlementPaymentTypeId);
settlementNote = settlement != null
? "Settled " + settlement.amount() + " to linked savings account (txn #" + settlement.savingsTransactionId() + ")"
: "No linked savings account on this share account — settlement must be completed manually.";
}
final String updatedLog = buildActivityLog((String) record.get("activity_log"), "APPROVED", currentUser.getUsername(),
settlementNote);
@@ -696,7 +773,8 @@ public class ClerkShareWithdrawalRequestApiResource {
settlement_amount = ?, settlement_savings_transaction_id = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), shareTxnId, updatedLog,
Timestamp.from(Instant.now()), settlement != null ? settlement.amount() : null,
Timestamp.from(Instant.now()),
settlement != null ? settlement.amount() : (glSettlement != null ? glSettlement.amount() : null),
settlement != null ? settlement.savingsTransactionId() : null, id);
if (approvedRows == 0) {
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
@@ -712,7 +790,9 @@ public class ClerkShareWithdrawalRequestApiResource {
// Every other clerk register raises a voucher for the money it moves; this one did not, so a
// redemption settled into a member's savings account left nothing on the voucher register to
// read it back from. The GL entries were always written — it is the slip that was missing.
final String voucherNo = raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
final String voucherNo = "NONE".equals(settlementMode) ? raiseSundryOnlyVoucher(id, shareTransactionId, currentUser)
: "GL".equals(settlementMode) ? raiseGlShareWithdrawalVoucher(id, shareAccountId, record, glSettlement, currentUser)
: raiseShareWithdrawalVoucher(id, shareAccountId, record, settlement, currentUser);
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
@@ -723,7 +803,9 @@ public class ClerkShareWithdrawalRequestApiResource {
if (shareTxnId != null) {
res.addProperty("shareTransactionId", shareTxnId);
}
if (settlement != null) {
if (glSettlement != null) {
res.addProperty("settlementAmount", glSettlement.amount());
} else if (settlement != null) {
res.addProperty("settlementAmount", settlement.amount());
res.addProperty("settlementSavingsTransactionId", settlement.savingsTransactionId());
} else {
@@ -786,17 +868,18 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long savingsAccountId = jdbcTemplate
.queryForList("SELECT savings_account_id FROM m_share_account WHERE id = ?", Long.class, shareAccountId).stream()
.findFirst().orElse(null);
final Long glSavingsControl = savingsAccountId == null ? null : savingsControlGlOf(savingsAccountId);
final String memberName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) : "Member";
final String shareAccountNo = record.get("account_no") == null ? null : String.valueOf(record.get("account_no"));
final String savingsAccountNo = savingsAccountId == null ? null
: jdbcTemplate.queryForList("SELECT account_no FROM m_savings_account WHERE id = ?", String.class, savingsAccountId)
.stream().findFirst().orElse(null);
final List<VoucherLine> lines = new ArrayList<>();
lines.add(VoucherLine.debitGl(glSundry, "Share Sundry", shareAccountNo, settlement.amount()));
lines.add(VoucherLine.creditGl(glSavingsControl, memberName, savingsAccountNo, settlement.amount()));
lines.add(VoucherLine.debitGl(glSundry, glNameOf(glSundry), glCodeOf(glSundry), settlement.amount()));
// This leg is the member's own savings account receiving the redemption proceeds, not a
// second GL posting -- creditGl(glSavingsControl, ...) named the shared Savings Control GL
// account here, mismatched against the member's own account number on the same line.
lines.add(VoucherLine.creditSavings(savingsAccountId, memberName, savingsAccountNo, settlement.amount()));
// PREFIX_CREDIT, not DEBIT: from the member's side this is money arriving in their
// savings account, which is the receipt series — the share account is the source, not a
@@ -807,6 +890,14 @@ public class ClerkShareWithdrawalRequestApiResource {
voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId,
settlement.savingsTransactionId());
}
// Every other request type that raises a voucher stores its own voucher_no back on
// itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers
// find it -- without this, the voucher exists in m_voucher/m_voucher_line but is
// invisible everywhere a register looks it up by its owning request, understating the
// branch's Payment total and the Day Register's expected cash by exactly this amount.
if (voucherNo != null) {
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
}
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a voucher for share withdrawal request {}", requestId, e);
@@ -816,6 +907,167 @@ public class ClerkShareWithdrawalRequestApiResource {
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
private record GlSettlement(BigDecimal amount, Long glAccountId) {}
/**
* Books the redemption value to whichever GL account the clerk picked on the CR row of the Fund
* Transfer, routed Dr Share Sundry / Cr that GL account — the GL counterpart to
* {@link #creditLinkedSavingsAccount} for a settlement that never touches a member's own account
* (e.g. crediting a "DD Payable" holding account, so a second, independent manual entry can later
* move the value out to the actual demand draft). Posted in full at approval time, exactly like
* the Sundry leg before it.
*
* <p>
* Unlike a cash settlement, nothing physical changes hands here, so the amount is not rounded —
* the redemption value posts to the paisa, same as the linked-savings path.
*/
private GlSettlement postGlSettlement(final Long shareAccountId, final Long shareTransactionId, final String requestedDateStr,
final Long settlementGlAccountId) {
if (shareTransactionId == null) {
return null;
}
if (settlementGlAccountId == null) {
throw apiError(409, "No settlement GL account was recorded on this request.");
}
final Map<String, Object> row = jdbcTemplate.queryForList("""
SELECT sat.amount AS settlementAmount FROM m_share_account_transactions sat WHERE sat.id = ?
""", shareTransactionId).stream().findFirst().orElse(null);
if (row == null) {
return null;
}
final BigDecimal settlementAmount = (BigDecimal) row.get("settlementAmount");
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final String accountNo = (String) acct.get("account_no");
final Long glSundry = requireShareGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final LocalDate entryDate = LocalDate.parse(requestedDateStr.substring(0, 10));
final long userId = context.authenticatedUser().getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHRGLSTL-" + System.nanoTime();
insertEntry(officeId, glSundry, DEBIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption settled to GL account | " + accountNo);
insertEntry(officeId, settlementGlAccountId, CREDIT, settlementAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share redemption settlement credited to GL account | " + accountNo);
return new GlSettlement(settlementAmount, settlementGlAccountId);
}
/**
* The voucher for an approved GL redemption: Dr Share Sundry, Cr the clerk-chosen settlement GL
* account — the same two accounts {@link #postGlSettlement} actually posted to, both named and
* coded from the ledger since both are real GL accounts here (unlike the linked-savings path,
* there is no member-account leg on this side at all).
*/
private String raiseGlShareWithdrawalVoucher(final Long requestId, final Long shareAccountId, final Map<?, ?> record,
final GlSettlement glSettlement, final AppUser currentUser) {
if (!voucherService.shouldRaiseVoucher() || glSettlement == null || glSettlement.amount() == null
|| glSettlement.amount().compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
try {
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
shareAccountId);
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
final Long glTarget = glSettlement.glAccountId();
final List<VoucherLine> lines = new ArrayList<>();
lines.add(VoucherLine.debitGl(glSundry, glNameOf(glSundry), glCodeOf(glSundry), glSettlement.amount()));
lines.add(VoucherLine.creditGl(glTarget, glNameOf(glTarget), glCodeOf(glTarget), glSettlement.amount()));
// PREFIX_DEBIT, not CREDIT: unlike the linked-savings path, this settlement never credits
// a member's own account -- the value is moving out of Sundry toward an eventual payout
// (e.g. a second, independent manual entry crediting a "DD Payable" account out to the
// actual draft), which is the payment series, not a receipt.
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
// Every other request type that raises a voucher stores its own voucher_no back on
// itself, which is how "My Voucher"/"My Work" and the Received/Payment/Journal registers
// find it -- without this, the voucher exists in m_voucher/m_voucher_line but is
// invisible everywhere a register looks it up by its owning request.
if (voucherNo != null) {
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
}
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a GL voucher for share withdrawal request {}", requestId, e);
return null;
}
}
/**
* The voucher for a request that settles nowhere further than leg 1 itself (settlement mode
* 'NONE'): Dr Share Capital, Cr Share Sundry — exactly what {@link #routeRedeemIntoSundry} just
* posted, given its own standalone voucher instead of only being visible as "Also posted" under
* some other request's voucher (see {@code VoucherVerificationApiResource.relatedGlEntries()}).
*
* <p>
* Read back from {@code acc_gl_journal_entry} by the native transaction's own transaction_id
* (the same technique {@code relatedGlEntries()} uses) rather than re-resolving the Shares
* Equity GL directly — there is no {@code FA_SHARES_EQUITY} constant in this class because that
* leg has never been posted by this class itself, only by Fineract's own native redeem command;
* reading back what was actually posted is correct regardless of which GL that turns out to be.
*
* <p>
* Capped to the 2 most recently inserted matching rows (highest id), not every row under that
* transaction_id: {@code "SH" + shareTransactionId} is only unique so long as
* {@code m_share_account_transactions.id} never repeats, which a reset/reseeded id sequence in a
* dev/test environment can violate — an older, unrelated redemption that happened to land on the
* same id would otherwise be pulled in as a second, phantom copy of this leg, doubling the total
* shown here even though only one pair was ever posted for this approval. This call runs
* immediately after {@link #routeRedeemIntoSundry} in the same approve(), so the highest-id pair
* is always the one it just inserted.
*/
private String raiseSundryOnlyVoucher(final Long requestId, final Long shareTransactionId, final AppUser currentUser) {
if (!voucherService.shouldRaiseVoucher() || shareTransactionId == null) {
return null;
}
try {
final List<String> nativeTxnIds = jdbcTemplate.queryForList(
"SELECT DISTINCT transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ?", String.class,
shareTransactionId);
if (nativeTxnIds.isEmpty()) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT je.type_enum, je.amount, ga.id AS gl_id, ga.name AS gl_name, ga.gl_code
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.id DESC
LIMIT 2
""", nativeTxnIds.get(0));
if (rows.isEmpty()) {
return null;
}
// Restore Dr-then-Cr reading order after the DESC/LIMIT above.
java.util.Collections.reverse(rows);
final List<VoucherLine> lines = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Long glId = ((Number) r.get("gl_id")).longValue();
final BigDecimal amount = (BigDecimal) r.get("amount");
final boolean isDebit = ((Number) r.get("type_enum")).intValue() == DEBIT;
lines.add(isDebit ? VoucherLine.debitGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount)
: VoucherLine.creditGl(glId, (String) r.get("gl_name"), (String) r.get("gl_code"), amount));
}
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL,
VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
if (voucherNo != null) {
jdbcTemplate.update("UPDATE m_clerk_share_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, requestId);
}
return voucherNo;
} catch (final RuntimeException e) {
LOG.error("Could not raise a Sundry-only voucher for share withdrawal request {}", requestId, e);
return null;
}
}
/**
* Credits the redemption value to the share account's linked savings account, if one exists.
* Reads the amount straight off the just-created redeem transaction row rather than re-deriving
@@ -875,7 +1127,12 @@ public class ClerkShareWithdrawalRequestApiResource {
// The deposit's own GL entries (whatever they turned out to be) are reversed wholesale rather
// than picked apart leg-by-leg — the correct Dr Sundry / Cr Savings Control pair below stands
// on its own regardless of which GL the payment type's Fund Source mapping happened to hit.
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTransactionId);
// Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g.
// after a sequence reset), this can only flip rows that are currently live, never
// re-touch an already-reversed unrelated transaction's rows.
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
"S" + savingsTransactionId);
final Map<String, Object> acct = loadShareAccountGlContext(shareAccountId);
final Long productId = toLong(acct.get("product_id"));
@@ -1050,6 +1307,39 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
/** The GL account's own code, for the {@code accountNo} printed on a voucher's GL-side line --
* never the account/party this GL entry happens to relate to, which is what the "name" already
* carries. Null-safe so a caller can pass a possibly-missing gl account id straight through. */
private String glCodeOf(final Long glAccountId) {
if (glAccountId == null) {
return null;
}
try {
return jdbcTemplate.queryForObject("SELECT gl_code FROM acc_gl_account WHERE id = ?", String.class, glAccountId);
} catch (final IncorrectResultSizeDataAccessException e) {
return null;
}
}
/** The GL account's own configured name (e.g. "SUNDRY CREDITORS A/C"), for the party label on a
* voucher's GL-side line -- fixed labels like "Share Sundry" or "Cash in Hand" used to stand in
* for whatever the tenant's own chart of accounts actually maps to (shares-suspense, cash at
* vault), so a voucher read as crediting/debiting a party that doesn't exist in the ledger at all
* whenever the real mapped account was named differently. Null-safe: a missing/unmapped account
* falls back to a neutral label rather than one of those specific fixed names, which would be
* actively wrong when reused for a different GL account (as glCodeOf's callers do). */
private String glNameOf(final Long glAccountId) {
if (glAccountId == null) {
return "GL Account";
}
try {
final String name = jdbcTemplate.queryForObject("SELECT name FROM acc_gl_account WHERE id = ?", String.class, glAccountId);
return name != null ? name : "GL Account";
} catch (final IncorrectResultSizeDataAccessException e) {
return "GL Account";
}
}
private Long savingsControlGlOf(final Long savingsAccountId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
@@ -484,7 +484,7 @@ public class ClerkWithdrawalRequestApiResource {
// The sides of the slip. A withdrawal is money leaving the member, so their account is
// debited and the till that pays it out is credited.
addWithdrawalVoucherLines(record, savingsAccountId, amount);
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
@@ -735,28 +735,61 @@ public class ClerkWithdrawalRequestApiResource {
* Records the two sides of an approved withdrawal on its voucher.
*
* Money leaves the member and is paid out over the counter, so the member's account is debited
* and the till is credited. The withdrawal register does not carry a GL id for the till — it
* pays out of whichever cashier's drawer serves the request — so that side is named without one.
* and the till is credited. The till side can't be resolved from config the way a Share or
* Closure withdrawal's cash leg can: which GL Fineract's native withdrawal command actually
* credits depends on the request's own payment type (Cash, Cheque, DD, ...) via its Fund Source
* mapping, which this class does not re-derive. Reading back what was actually posted for this
* transaction (see resolveTillGlFromSavingsTxn()) names it accurately for any payment type;
* "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a current
* account withdrawal, or a tenant with accounting off).
*
* A failure here does not fail the approval: the withdrawal is already posted and the member
* already paid. The problem is logged, and the voucher keeps its number without sides.
*/
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount) {
private void addWithdrawalVoucherLines(final Map<?, ?> record, final Long savingsAccountId, final BigDecimal amount,
final String savingsTxnId) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank() || savingsAccountId == null) {
return;
}
try {
final Map<String, Object> tillGl = resolveTillGlFromSavingsTxn(savingsTxnId);
voucherService.addLines(voucherNo,
List.of(VoucherLine.debitSavings(savingsAccountId, (String) record.get("client_name"),
(String) record.get("account_no"), amount),
VoucherLine.creditGl(null, "Cash in Hand", null, amount)));
tillGl == null ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
: VoucherLine.creditGl(((Number) tillGl.get("id")).longValue(), (String) tillGl.get("name"),
(String) tillGl.get("code"), amount)));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for withdrawal request voucher {}", voucherNo, e);
}
}
/** The GL account Fineract's native withdrawal command actually credited for this savings
* transaction — looked up by the {@code savings_transaction_id} FK on {@code
* acc_gl_journal_entry} rather than assumed, since the real posting depends on the withdrawal's
* own payment type. Returns null (falls back to a generic label) for a current-account
* withdrawal, which has no savings transaction to match, or a tenant with accounting off. */
private Map<String, Object> resolveTillGlFromSavingsTxn(final String savingsTxnId) {
if (savingsTxnId == null) {
return null;
}
final Long id;
try {
id = Long.valueOf(savingsTxnId);
} catch (final NumberFormatException e) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT g.id, g.name, g.gl_code AS code FROM acc_gl_journal_entry je
JOIN acc_gl_account g ON g.id = je.account_id
WHERE je.savings_transaction_id = ? AND je.type_enum = 1 AND je.reversed = false
LIMIT 1
""", id);
return rows.isEmpty() ? null : rows.get(0);
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
@@ -571,12 +571,18 @@ public class DividendClaimRequestApiResource {
final Map<String, Object> debitGl = glAccountForVoucher(debitGlId);
final Map<String, Object> creditGl = glAccountForVoucher(creditGlId);
voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId, currentUser,
VoucherService.PREFIX_DEBIT,
final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_JOURNAL, REF_DIVIDEND_CLAIM, claimId,
currentUser, VoucherService.PREFIX_DEBIT,
List.of(VoucherLine.debitGl(debitGlId, (String) debitGl.get("name"), (String) debitGl.get("gl_code"), amount)
.withDescription(description),
VoucherLine.creditGl(creditGlId, (String) creditGl.get("name"), (String) creditGl.get("gl_code"), amount)
.withDescription(description)));
// Every other maker-checker request that raises a voucher stores its own voucher_no back
// on itself, which is how "My Voucher"/"My Work" finds it -- without this, the voucher
// existed in m_voucher/m_voucher_line but was invisible to the manager who approved it.
if (voucherNo != null) {
jdbcTemplate.update("UPDATE m_share_dividend_claim_request SET voucher_no = ? WHERE id = ?", voucherNo, claimId);
}
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for dividend claim {}", claimId, e);
}
@@ -1111,19 +1111,34 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
try {
final AppUser currentUser = this.context.authenticatedUser();
final Map<String, Object> portfolio = this.voucherService.loanPortfolioAccount(loan.getId());
final Long portfolioGlId = portfolio.get("id") == null ? null : ((Number) portfolio.get("id")).longValue();
final String party = loan.getClient() != null ? loan.getClient().getDisplayName() : null;
final String accountNo = loan.getAccountNumber();
final String partyName = party != null ? party : (String) portfolio.get("name");
final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitGl(portfolioGlId, partyName, accountNo, amount)
: VoucherLine.creditGl(portfolioGlId, partyName, accountNo, amount);
// This is the borrower's own loan account leg, not a GL posting -- debitGl/creditGl(
// portfolioGlId, ...) named the shared Loan Portfolio GL account here, mismatched against
// this borrower's own loan account number on the same line.
final VoucherLine borrowerSide = isDisbursement ? VoucherLine.debitLoan(loan.getId(), partyName, accountNo, amount)
: VoucherLine.creditLoan(loan.getId(), partyName, accountNo, amount);
// The till. The loan module does not resolve a GL for the cashier's drawer, so the line
// names it without an id rather than guessing at one.
final VoucherLine cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
: VoucherLine.debitGl(null, "Cash in Hand", null, amount);
// The till. Which GL the native disbursement/repayment command actually posted here
// depends on the transaction's own payment type (Cash, Cheque, DD, ...) via its Fund
// Source mapping, so it's read back from what was actually posted rather than assumed --
// "Cash in Hand" with no GL only remains the label where that lookup finds nothing (a
// non-cash payment type routed elsewhere, or accounting off for this tenant).
final Map<String, Object> tillGl = this.voucherService.tillGlFromLoanTransaction(transactionId, isDisbursement);
final VoucherLine cashSide;
if (tillGl.isEmpty()) {
cashSide = isDisbursement ? VoucherLine.creditGl(null, "Cash in Hand", null, amount)
: VoucherLine.debitGl(null, "Cash in Hand", null, amount);
} else {
final Long tillGlId = ((Number) tillGl.get("id")).longValue();
final String tillGlName = (String) tillGl.get("name");
final String tillGlCode = (String) tillGl.get("gl_code");
cashSide = isDisbursement ? VoucherLine.creditGl(tillGlId, tillGlName, tillGlCode, amount)
: VoucherLine.debitGl(tillGlId, tillGlName, tillGlCode, amount);
}
final List<VoucherLine> lines = isDisbursement ? List.of(borrowerSide, cashSide) : List.of(cashSide, borrowerSide);
@@ -2079,7 +2079,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Map<String, Object> control = this.voucherService.savingsControlAccount(account.getId());
final Map<String, Object> expense = this.voucherService.savingsInterestExpenseAccount(account.getId());
final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue();
final Long expenseGlId = expense.get("id") == null ? null : ((Number) expense.get("id")).longValue();
final String party = account.getClient() != null ? account.getClient().getDisplayName() : null;
@@ -2100,8 +2099,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.voucherService.createVoucherWithLines(VoucherService.TYPE_INTEREST_POSTING, REF_SAVINGS_TRANSACTION,
transaction.getId(), currentUser, VoucherService.PREFIX_JOURNAL,
List.of(VoucherLine.debitGl(expenseGlId, (String) expense.get("name"), (String) expense.get("gl_code"), amount)
.withDescription("Interest posted to " + accountNo),
VoucherLine.creditGl(controlGlId, partyName, accountNo, amount)
.withDescription("Interest posted to " + accountNo),
// The member's own savings account leg, not a GL posting -- creditGl(
// controlGlId, ...) named the shared Savings Control GL account here,
// mismatched against this account's own number on the same line.
VoucherLine.creditSavings(account.getId(), partyName, accountNo, amount)
.withDescription("Interest credited")));
// Without this the voucher exists but nothing ties it to the transaction it paid, so
// the register cannot show the two together and the transaction shows no voucher_no.
@@ -220,7 +220,12 @@ public class AdditionalShareTrancheFundingService {
// as a real entry. Its Dr Cash / Cr Savings Control pair is half wrong — no cash came in
// over the counter — so the cash debit is replaced by a Sundry debit below.
final Long savingsTxnId = executeSavingsDeposit(savingsAccountId, amount, entryDate, note);
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
// Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g.
// after a sequence reset), this can only flip rows that are currently live, never
// re-touch an already-reversed unrelated transaction's rows.
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
"S" + savingsTxnId);
// Fineract's stock REJECTED posting credits Shares Reference. Nothing is going back to the
// till, so that credit is reversed; the Sundry debit it raised alongside is correct and
@@ -344,7 +349,12 @@ public class AdditionalShareTrancheFundingService {
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
// pair (Dr Savings Control / Cr Cash) is reversed below: no cash left the branch.
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, amount, entryDate, note);
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
// Guarded by reversed = false: if the "S"+id transaction_id text is ever reused (e.g.
// after a sequence reset), this can only flip rows that are currently live, never
// re-touch an already-reversed unrelated transaction's rows.
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
"S" + savingsTxnId);
// Fineract's stock APPLIED leg for a purchase posts Dr Shares Reference / Cr Share Sundry,
// assuming cash over the counter. BOTH legs are reversed, not just the reference debit:
@@ -279,7 +279,8 @@ public class SponsorDDApiResource {
request.getChequeNumber(),
request.getChequeBankName(),
request.getCashierDepositRequestId(),
request.getDenominationData()
request.getDenominationData(),
request.getGlAccountId()
));
}
@@ -673,6 +674,9 @@ public class SponsorDDApiResource {
// that received this cash, credited to the DD Cash Suspense GL account.
private Long cashierDepositRequestId;
private String denominationData;
// GL only — the GL account the clerk chose to debit directly, instead of a member's own
// account, a cheque, or cash.
private Long glAccountId;
}
@lombok.Data
@@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService {
String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch,
String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate,
String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName,
Long cashierDepositRequestId, String denominationData);
Long cashierDepositRequestId, String denominationData, Long glAccountId);
/**
* Manager/head-cashier (checker) step: performs the real debit/credit GL posting
@@ -60,6 +60,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
private static final String METHOD_ACCOUNT_DEBIT = "ACCOUNT_DEBIT";
private static final String METHOD_CHEQUE = "CHEQUE";
private static final String METHOD_CASH = "CASH";
// A clerk debits a GL account directly (an institutional/holding account) instead of a member's
// own account, a cheque, or cash over the counter -- no client, no balance check, no walk-in
// purchaser details.
private static final String METHOD_GL = "GL";
/** A Sponsor DD issue, keyed by its m_sponsor_dd_issue_request id. */
private static final String REF_SPONSOR_DD_ISSUE = "SPONSOR_DD_ISSUE_REQUEST";
@@ -101,7 +105,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount,
final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId,
final Long currentAccountId, final String chequeNumber, final String chequeBankName,
final Long cashierDepositRequestId, final String denominationData) {
final Long cashierDepositRequestId, final String denominationData, final Long glAccountId) {
final AppUser currentUser = this.context.authenticatedUser();
@@ -177,6 +181,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
issuingBranchId, issuedDate, receiptComments, null);
cashReceiptVoucherPending = true;
}
} else if (METHOD_GL.equals(paymentMethod)) {
// No client, no walk-in purchaser -- an institutional draft funded straight from a GL
// account. Debiting the same account the draft is credited to (DD Payable) would be a
// self-cancelling entry with no real movement, the same bug class the Share GL-settlement
// check exists to catch.
if (glAccountId == null) {
throw new IllegalArgumentException("GL Account is required for a GL-funded DD issuance.");
}
final Long ddPayableGlAccountId = ((Number) glMapping.get("dd_payable_gl_account_id")).longValue();
if (glAccountId.equals(ddPayableGlAccountId)) {
final Map<String, Object> ddPayable = this.voucherService.glAccount(ddPayableGlAccountId);
throw new IllegalArgumentException("GL Account cannot be the same as the DD Payable account ("
+ ddPayable.get("gl_code") + ") this draft is credited to — pick a different GL account to debit.");
}
} else {
validateClientExists(clientId);
if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account");
@@ -200,8 +218,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
+ "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, "
+ "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, "
+ "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, "
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data, gl_account_id) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, leafletId);
if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT);
@@ -228,6 +246,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT);
if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT);
ps.setString(25, denominationData);
if (glAccountId != null) ps.setLong(26, glAccountId); else ps.setNull(26, java.sql.Types.BIGINT);
return ps;
}, kh);
@@ -441,8 +460,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
private Long findJournalEntryIdForCashierDeposit(final Map<String, Object> depositRequest, final Long cashSuspenseGlAccountId) {
final String txnId = (String) depositRequest.get("txn_id");
if (txnId == null) return null;
// ORDER BY id DESC: prefer the most recently posted row for this transaction_id text, in
// case the id is ever reused (e.g. after a sequence reset) rather than latching onto a
// stale, unrelated journal entry.
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? LIMIT 1", txnId, cashSuspenseGlAccountId);
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND account_id = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT 1", txnId, cashSuspenseGlAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue();
}
@@ -534,6 +557,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
}
} else if (METHOD_CHEQUE.equals(paymentMethod)) {
drGlAccountId = ((Number) glMapping.get("cheque_clearing_gl_account_id")).longValue();
} else if (METHOD_GL.equals(paymentMethod)) {
// The clerk's own chosen GL account -- no withdrawal command, no linked member account;
// submitIssueRequest() already confirmed it exists and differs from DD Payable.
drGlAccountId = ((Number) request.get("gl_account_id")).longValue();
} else if (METHOD_DIVIDEND.equals(paymentMethod)) {
// Nothing is collected from the member — the society already owes them this money — so
// the draft is funded by settling the Dividend Payable liability instead of debiting a
@@ -1237,8 +1264,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
if (!walkinPurchaserAadhaar.replaceAll("\\s", "").matches("\\d{12}")) {
throw new IllegalArgumentException("Aadhaar Number must be exactly 12 digits.");
}
} else if (METHOD_GL.equals(paymentMethod)) {
if (clientId != null) {
throw new IllegalArgumentException("Do not provide a Client ID for a GL-funded DD issuance — it is not raised against a member.");
}
if (savingsAccountId != null || currentAccountId != null || (chequeNumber != null && !chequeNumber.isBlank())) {
throw new IllegalArgumentException("Do not provide an account or cheque details when funding a draft from a GL account.");
}
} else {
throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, or CASH.");
throw new IllegalArgumentException("paymentMethod must be ACCOUNT_DEBIT, CHEQUE, CASH, or GL.");
}
}
@@ -1325,7 +1359,11 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
* Payable entry, posted via postGlOffsetEntry() right after this call, is visible.
*/
private void markWithdrawalGlEntriesReversed(final String transactionId) {
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", transactionId);
// Guarded by reversed = false: if transaction_id text is ever reused, this can only flip
// rows that are currently live, never re-touch an already-reversed unrelated transaction.
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ? AND reversed = false",
transactionId);
}
/** Returns the Savings Control GL account ID for this savings account's product (from acc_product_mapping). */
@@ -1374,8 +1412,17 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.creditGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total)
.withDescription("Held pending DD approval"));
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, issueRequestId,
currentUser, VoucherService.PREFIX_CREDIT, lines);
// Every other maker-checker request that raises a voucher stores its own voucher_no back
// on itself, which is how "My Voucher"/"My Work" finds it. A DD request can raise up to
// three vouchers across its life (this intake receipt, an approval issuance, or a
// rejection refund), so the column holds whichever was raised most recently -- still far
// better than the total blank it was before, and this is the one that exists on every DD
// paid in cash, receipt or not.
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE,
issueRequestId, currentUser, VoucherService.PREFIX_CREDIT, lines);
if (voucherNo != null) {
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
}
} catch (final RuntimeException e) {
log.error("Could not raise a cash receipt voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1406,8 +1453,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
lines.add(VoucherLine.creditGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total)
.withDescription("Cash refunded to purchaser"));
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
currentUser, VoucherService.PREFIX_DEBIT, lines);
// See the same lines in raiseCashReceiptVoucher() above for why this is required.
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE,
issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
if (voucherNo != null) {
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
}
} catch (final RuntimeException e) {
log.error("Could not raise a cash refund voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1467,8 +1518,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
.withDescription("GST on DD charges"));
}
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
currentUser, VoucherService.PREFIX_DEBIT, lines);
// See the same lines in raiseCashReceiptVoucher() above for why this is required.
final String voucherNo = this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE,
issueRequestId, currentUser, VoucherService.PREFIX_DEBIT, lines);
if (voucherNo != null) {
this.jdbcTemplate.update("UPDATE m_sponsor_dd_issue_request SET voucher_no = ? WHERE id = ?", voucherNo, issueRequestId);
}
} catch (final RuntimeException e) {
log.error("Could not raise a voucher for Sponsor DD issue request {}", issueRequestId, e);
}
@@ -1592,14 +1647,21 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String transactionId = result != null ? result.getTransactionId() : null;
if (transactionId == null) return null;
// transactionId was just minted by the createJournalEntry command above, in this same
// method invocation - but scope every read/write below to the single most recently
// posted row anyway (ORDER BY id DESC), as defense-in-depth against the id text ever
// being reused (e.g. after a sequence reset) rather than trusting uniqueness implicitly.
if (linkedAccountId != null) {
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE transaction_id = ? AND type_enum = 2",
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ("
+ "SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = 2 AND reversed = false"
+ " ORDER BY id DESC LIMIT 1)",
linkedAccountId, transactionId);
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? LIMIT 1", transactionId);
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND reversed = false"
+ " ORDER BY id DESC LIMIT 1", transactionId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("id")).longValue();
}
@@ -748,9 +748,22 @@ public class VoucherVerificationApiResource {
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, manager_id, manager_name, actioned_at
FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL
UNION ALL
-- This table only ever stored the checker's id, never a name snapshot the
-- way every other register here does, so approver_name comes back null and
-- the outer query's COALESCE to the verification row's name is what fills it
-- in on this one register.
SELECT voucher_no, checker_id, CAST(NULL AS VARCHAR), checker_date
FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL
),
-- Who submitted the request the voucher belongs to.
--
@@ -766,8 +779,14 @@ public class VoucherVerificationApiResource {
SELECT voucher_no, clerk_id, status FROM m_clerk_closure_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_clerk_disbursement_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_clerk_share_withdrawal_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, cashier_id, status FROM m_cashier_deposit_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, clerk_id, status FROM m_share_dividend_claim_request WHERE voucher_no IS NOT NULL
UNION ALL
SELECT voucher_no, maker_id, status FROM m_sponsor_dd_issue_request WHERE voucher_no IS NOT NULL
)
SELECT v.voucher_no, v.voucher_type, v.amount, v.created_at,
v.created_by_username, o.name AS branch_name,
@@ -917,6 +936,94 @@ public class VoucherVerificationApiResource {
}
}
/**
* The other side of a split transaction — GL entries a native platform command posted on its own,
* outside this voucher, for the same underlying event.
*
* Most vouchers already ARE the whole transaction: {@code raiseLoanVoucher()},
* {@code raiseClosureVoucher()} and the rest each build every line themselves from the amount they
* are posting, so there is nothing else to find. Share redemption is the one place today where
* that is not true: approval runs the native {@code redeemshares} command, which posts its own
* Dr Share Capital / Cr Share Sundry pair directly (no voucher raised for it, since it is not this
* class that posts it). Only the later savings-settlement leg (Dr Sundry / Cr Savings Control)
* becomes a voucher. Showing just that half leaves a reader unable to see where the money
* actually came from.
*
* Extending this for a future split transaction is one more {@code if} branch keyed on
* referenceType, in the same shape as the share-withdrawal one below — not a new endpoint or a new
* field on the response.
*/
private List<Map<String, Object>> relatedGlEntries(final String referenceType, final Object referenceId) {
if (!VoucherService.REF_CLERK_SHARE_WITHDRAWAL_REQUEST.equals(referenceType) || referenceId == null) {
return List.of();
}
final List<Map<String, Object>> requestRows = jdbcTemplate.queryForList(
"SELECT share_transaction_id, settlement_mode, actioned_at FROM m_clerk_share_withdrawal_request "
+ "WHERE id = ? AND share_transaction_id IS NOT NULL",
referenceId);
if (requestRows.isEmpty()) {
return List.of();
}
// A settlement_mode 'NONE' request's own voucher (raiseSundryOnlyVoucher()) already IS leg 1
// in full -- Dr Share Capital, Cr Share Sundry -- so showing it again here as "related" would
// duplicate this voucher's own lines under a banner claiming they are not part of it.
if ("NONE".equals(requestRows.get(0).get("settlement_mode"))) {
return List.of();
}
final Long shareTxnId = Long.parseLong(String.valueOf(requestRows.get(0).get("share_transaction_id")));
final java.sql.Timestamp actionedAt = (java.sql.Timestamp) requestRows.get(0).get("actioned_at");
// Matched by transaction_id, not share_transaction_id: routeRedeemIntoSundry() reroutes the
// native posting's Cr Shares Reference leg by reversing it and inserting a fresh Cr Sundry row
// that carries the same transaction_id but no share_transaction_id of its own (only the native
// rows get that column populated) -- filtering on share_transaction_id found the Dr Link Share
// leg but silently dropped its Cr Sundry counterpart, showing only half of a balanced entry.
// transaction_id is what groups a balanced entry in this codebase (see routeRedeemIntoSundry's
// own comment), and the savings-settlement leg raiseShareWithdrawalVoucher() itself posts
// carries its own distinct transaction_id, so this still cleanly excludes it — that leg is
// already covered by this voucher's own lines.
//
// "SH" + shareTransactionId is only unique so long as m_share_account_transactions.id never
// repeats, which a reset/reseeded id sequence in a dev/test environment can violate -- two
// unrelated redemptions landing on the same id would otherwise share one transaction_id, and
// this query would return both pairs instead of the one this specific request actually
// posted. Disambiguated by picking whichever candidate transaction_id's own entry_date sits
// closest to actioned_at (when this request was approved), rather than assuming there is
// only ever one.
final List<Map<String, Object>> nativeTxnCandidates = actionedAt == null
? jdbcTemplate.queryForList("SELECT DISTINCT transaction_id FROM acc_gl_journal_entry WHERE share_transaction_id = ?",
shareTxnId)
: jdbcTemplate.queryForList("""
SELECT transaction_id FROM acc_gl_journal_entry
WHERE share_transaction_id = ?
GROUP BY transaction_id
ORDER BY MIN(ABS(EXTRACT(EPOCH FROM (created_date - ?::timestamp)))) ASC
LIMIT 1
""", shareTxnId, actionedAt);
if (nativeTxnCandidates.isEmpty()) {
return List.of();
}
final String nativeTxnId = String.valueOf(nativeTxnCandidates.get(0).get("transaction_id"));
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT je.entry_date, je.type_enum, je.amount, je.description, ga.name AS gl_name, ga.gl_code
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.id
""", nativeTxnId);
final List<Map<String, Object>> out = new java.util.ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("stage", "Posted at manager approval");
m.put("entryType", ((Number) r.get("type_enum")).intValue() == 2 ? "DR" : "CR");
m.put("partyName", r.get("gl_name"));
m.put("accountNo", r.get("gl_code"));
m.put("amount", r.get("amount"));
m.put("description", r.get("description"));
out.add(m);
}
return out;
}
// ── GET /v1/vouchers/detail?voucherNo=CR/2708/0001 ────────────────────────
// A query parameter for the same reason as verify: a formatted voucher number contains
// slashes, which the strict firewall rejects even encoded, so it cannot be a path segment.
@@ -1000,6 +1107,7 @@ public class VoucherVerificationApiResource {
// existed, or repaired by hand in the database, should show as unbalanced rather than be
// presented as a valid slip.
out.put("balanced", totalDebit.compareTo(totalCredit) == 0);
out.put("relatedLines", relatedGlEntries((String) h.get("reference_type"), h.get("reference_id")));
return gson.toJson(out);
}
@@ -326,6 +326,8 @@ public class VoucherService {
final Map<String, Object> party = savingsPartyForTransaction(savingsTxnId);
final String partyName = (String) party.get("party_name");
final String accountNo = (String) party.get("account_no");
final Long savingsAccountId = party.get("savings_account_id") == null ? null
: ((Number) party.get("savings_account_id")).longValue();
final Long controlGlId = party.get("control_gl_id") == null ? null : ((Number) party.get("control_gl_id")).longValue();
final List<VoucherLine> lines = new ArrayList<>();
@@ -335,13 +337,24 @@ public class VoucherService {
// The member's own side is named after the member; every other leg is named after
// its GL account, which is what a voucher shows for an income or expense head.
final boolean isMemberSide = controlGlId != null && controlGlId.equals(glId);
final String name = isMemberSide && partyName != null ? partyName : (String) row.get("gl_name");
final String ref = isMemberSide ? accountNo : (String) row.get("gl_code");
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT.
final int typeEnum = ((Number) row.get("type_enum")).intValue();
VoucherLine line = typeEnum == 2 ? VoucherLine.debitGl(glId, name, ref, amount)
: VoucherLine.creditGl(glId, name, ref, amount);
final boolean isDebit = typeEnum == 2;
final VoucherLine line;
if (isMemberSide) {
// This is the member's own savings account leg, not a GL posting -- pairing the
// shared Savings Control GL id with the member's own account number on a debitGl/
// creditGl line mismatched the two; debitSavings/creditSavings name the account
// this actually is.
line = isDebit ? VoucherLine.debitSavings(savingsAccountId, partyName, accountNo, amount)
: VoucherLine.creditSavings(savingsAccountId, partyName, accountNo, amount);
} else {
final String name = (String) row.get("gl_name");
final String ref = (String) row.get("gl_code");
line = isDebit ? VoucherLine.debitGl(glId, name, ref, amount) : VoucherLine.creditGl(glId, name, ref, amount);
}
final String description = (String) row.get("description");
lines.add(description == null ? line : line.withDescription(description));
}
@@ -370,6 +383,7 @@ public class VoucherService {
private Map<String, Object> savingsPartyForTransaction(final Long savingsTxnId) {
return jdbcTemplate.queryForList("""
SELECT COALESCE(c.display_name, g.display_name) AS party_name,
sa.id AS savings_account_id,
sa.account_no,
apm.gl_account_id AS control_gl_id
FROM m_savings_account_transaction t
@@ -536,6 +550,29 @@ public class VoucherService {
.orElse(Map.of());
}
/**
* The GL account Fineract's native loan disbursement/repayment command actually posted the cash
* leg to, for a voucher's till side — looked up by the {@code loan_transaction_id} FK on {@code
* acc_gl_journal_entry} rather than assumed, since which GL that command credits (a disbursement)
* or debits (a repayment) depends on the transaction's own payment type via its Fund Source
* mapping, not a single fixed cash account. Empty rather than absent if the transaction posted no
* matching leg (a non-cash payment type routed elsewhere, or accounting off for this tenant) —
* callers show a generic label in that case rather than a wrong or missing one.
*/
public Map<String, Object> tillGlFromLoanTransaction(final Long loanTransactionId, final boolean wantCreditLeg) {
if (loanTransactionId == null) {
return Map.of();
}
// type_enum: 1 = credit, 2 = debit.
final int typeEnum = wantCreditLeg ? 1 : 2;
return jdbcTemplate.queryForList("""
SELECT g.id, g.name, g.gl_code FROM acc_gl_journal_entry je
JOIN acc_gl_account g ON g.id = je.account_id
WHERE je.loan_transaction_id = ? AND je.type_enum = ? AND je.reversed = false
LIMIT 1
""", loanTransactionId, typeEnum).stream().findFirst().orElse(Map.of());
}
/**
* The sides of a voucher, in print order. Empty for a voucher raised before lines existed, or
* one whose accounts were never recorded — callers show what they have rather than treating an
@@ -102,4 +102,10 @@
<include file="parts/0560_client_kyc_risk_profile_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0561_add_client_behaviour_window_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0562_behaviour_window_distinct_months.xml" relativeToChangelogFile="true"/>
<!-- Same parallel-branch prefix overlap as noted above: 0551-0554 below repeat numbers already
used by the AML set, but the filenames differ, so the changeset identities stay distinct. -->
<include file="parts/0551_add_settlement_gl_account_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0552_add_voucher_no_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<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"/>
</databaseChangeLog>
@@ -0,0 +1,22 @@
<?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 GL settlement (settlement_mode = 'GL') credits whichever GL account the clerk picked on
the Fund Transfer's CR row, instead of the member's linked savings account: this column
records which one, so approve() knows where to post and the voucher can name it correctly. -->
<changeSet author="fineract" id="0551-1-add-settlement-gl-account">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="settlement_gl_account_id"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="settlement_gl_account_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?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">
<!-- Every other request type that raises a voucher (m_clerk_withdrawal_request,
m_clerk_closure_request, m_clerk_disbursement_request, m_cashier_deposit_request) stores the
voucher_no it was given back on itself, so VoucherVerificationApiResource.myWork() and the
Received/Payment/Journal registers can find it by joining on this table. Share withdrawal
never got this column, so raiseShareWithdrawalVoucher()'s vouchers existed in
m_voucher/m_voucher_line but were invisible everywhere a register looks a voucher up by its
owning request: "My Voucher"/"My Work" silently dropped them, and Payment totals came up
short by exactly what had been correctly posted and disbursed. -->
<changeSet author="fineract" id="0552-1-add-voucher-no">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_clerk_share_withdrawal_request" columnName="voucher_no"/>
</not>
</preConditions>
<addColumn tableName="m_clerk_share_withdrawal_request">
<column name="voucher_no" type="VARCHAR(50)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,38 @@
<?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">
<!-- Same gap as 0552 for m_clerk_share_withdrawal_request, found by the same audit: both of
these maker-checker request tables raise a voucher (DividendClaimRequestApiResource,
SponsorDDIssueRequestWritePlatformServiceImpl) but never stored voucher_no back on
themselves, so VoucherVerificationApiResource.myWork() could never find it: a manager who
approved a dividend cash payout or a DD issuance saw it nowhere in "My Voucher"/"My Work",
and the branch's Payment total came up short by exactly what had been paid out. -->
<changeSet author="fineract" id="0553-1-add-voucher-no-dividend-claim">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_share_dividend_claim_request" columnName="voucher_no"/>
</not>
</preConditions>
<addColumn tableName="m_share_dividend_claim_request">
<column name="voucher_no" type="VARCHAR(50)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0553-2-add-voucher-no-sponsor-dd">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="voucher_no"/>
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="voucher_no" type="VARCHAR(50)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,22 @@
<?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 clerk funding a Demand Draft by debiting a GL account directly (payment_method = 'GL'),
instead of a member's savings/current account, a cheque or cash: this column records which
GL account approveIssueRequest() debits, credited against the sponsor's own DD Payable GL. -->
<changeSet author="fineract" id="0554-1-add-gl-account-to-sponsor-dd-issue-request">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="gl_account_id"/>
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="gl_account_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>