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
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:
+20
-1
@@ -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
|
||||
|
||||
+19
-1
@@ -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();
|
||||
|
||||
+152
-82
@@ -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");
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+106
-12
@@ -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) {
|
||||
|
||||
+38
-5
@@ -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("""
|
||||
|
||||
+330
-40
@@ -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
|
||||
|
||||
+38
-5
@@ -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,
|
||||
|
||||
+8
-2
@@ -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);
|
||||
}
|
||||
|
||||
+22
-7
@@ -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);
|
||||
|
||||
|
||||
+5
-3
@@ -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.
|
||||
|
||||
+12
-2
@@ -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:
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+76
-14
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+108
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+41
-4
@@ -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>
|
||||
|
||||
+22
@@ -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>
|
||||
+27
@@ -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>
|
||||
+38
@@ -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>
|
||||
+22
@@ -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>
|
||||
Reference in New Issue
Block a user