feat(cashier): fix current account transactions search and withdrawal validations

CurrentAccountTransactionsApiResource:
- Fixed: search endpoint now queries m_current_account_transaction
  instead of m_savings_account_transaction (wrong table)
- Added remarks column to search query (column exists in CA table)
- Charge Deduction (type=7) mapped in transactionType value
- Removed sat.remarks that caused 500 (column doesn't exist in savings table)

CurrentAccountTransactionWritePlatformServiceImpl:
- Added min required balance validation in withdrawal()
- Throws GeneralPlatformDomainRuleException when balance after withdrawal
  would fall below minRequiredBalance (if enforceMinRequiredBalance=true)

StandingInstructionReadPlatformServiceImpl:
- Fixed SQL WHERE clause: fromAccountType + fromAccount condition
  now correctly adds WHERE when fromAccountId is set

Liquibase 0315:
- Fix standing instruction permission code (trim trailing space on id=449)
- Grant READ permissions to CASHIER role for transaction entry screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-05 22:27:59 +05:30
co-authored by Claude Sonnet 4.6
parent 2371ad98b1
commit edaeb674d5
4 changed files with 125 additions and 18 deletions
@@ -264,7 +264,8 @@ public class StandingInstructionReadPlatformServiceImpl implements StandingInstr
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
sqlBuilder.append(this.standingInstructionMapper.schema());
if (standingInstructionDTO.transferType() != null || standingInstructionDTO.clientId() != null
|| standingInstructionDTO.clientName() != null) {
|| standingInstructionDTO.clientName() != null
|| (standingInstructionDTO.fromAccountType() != null && standingInstructionDTO.fromAccount() != null)) {
sqlBuilder.append(" where ");
}
boolean addAndCaluse = false;
@@ -198,6 +198,84 @@ public class CurrentAccountTransactionsApiResource {
return toApiJsonSerializer.serialize(result);
}
/**
* GET /currentaccounts/{accountId}/transactions/search?fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD&limit=20
*
* Date-filtered transaction search for current accounts — mirrors the savings account search
* endpoint so the cashier transaction entry screen can use a consistent API across all types.
*/
@GET
@Path("search")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Search current account transactions by date range")
@ApiResponse(responseCode = "200", description = "OK")
public String searchTransactions(
@PathParam("accountId") final Long accountId,
@QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate,
@QueryParam("limit") @jakarta.ws.rs.DefaultValue("20") final int limit,
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") final int offset) {
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
final StringBuilder sql = new StringBuilder(
"SELECT cat.id, cat.transaction_type_enum, cat.transaction_date, cat.amount, "
+ "cat.running_balance_derived, cat.is_reversed, cat.ref_no, cat.remarks "
+ "FROM m_current_account_transaction cat "
+ "WHERE cat.current_account_id = ? AND cat.is_reversed = false ");
final java.util.List<Object> params = new java.util.ArrayList<>();
params.add(accountId);
if (fromDate != null && !fromDate.isBlank()) {
sql.append("AND cat.transaction_date >= CAST(? AS DATE) ");
params.add(fromDate);
}
if (toDate != null && !toDate.isBlank()) {
sql.append("AND cat.transaction_date <= CAST(? AS DATE) ");
params.add(toDate);
}
sql.append("ORDER BY cat.transaction_date DESC, cat.id DESC ");
sql.append("LIMIT ? OFFSET ?");
params.add(limit);
params.add(offset);
final java.util.List<java.util.Map<String, Object>> rows = jdbcTemplate.query(
sql.toString(),
(rs, rowNum) -> {
final java.util.Map<String, Object> row = new java.util.LinkedHashMap<>();
row.put("id", rs.getLong("id"));
final int typeEnum = rs.getInt("transaction_type_enum");
final java.util.Map<String, Object> txnType = new java.util.LinkedHashMap<>();
txnType.put("id", typeEnum);
txnType.put("deposit", typeEnum == 1);
txnType.put("withdrawal", typeEnum == 2);
txnType.put("value", typeEnum == 1 ? "Deposit" : typeEnum == 2 ? "Withdrawal"
: typeEnum == 7 ? "Charge Deduction" : "Other");
row.put("transactionType", txnType);
final java.sql.Date d = rs.getDate("transaction_date");
if (d != null) {
final java.time.LocalDate ld = d.toLocalDate();
row.put("date", new int[]{ ld.getYear(), ld.getMonthValue(), ld.getDayOfMonth() });
}
row.put("amount", rs.getBigDecimal("amount"));
row.put("runningBalance", rs.getBigDecimal("running_balance_derived"));
row.put("reversed", rs.getBoolean("is_reversed"));
row.put("refNo", rs.getString("ref_no"));
row.put("remarks", rs.getString("remarks"));
return row;
},
params.toArray());
final java.util.Map<String, Object> resp = new java.util.LinkedHashMap<>();
resp.put("totalFilteredRecords", rows.size());
resp.put("pageItems", rows);
return new com.google.gson.Gson().toJson(resp);
}
private boolean is(final String commandParam, final String commandValue) {
return StringUtils.isNotBlank(commandParam) && commandParam.trim().equalsIgnoreCase(commandValue);
}
@@ -132,11 +132,21 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
}
final String refNo = command.stringValueOfParameterNamed("accountNumber");
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
// Minimum required balance validation
final BigDecimal newBalance = currentBalance.subtract(amount);
if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null
&& account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0) {
if (newBalance.compareTo(account.getMinRequiredBalance()) < 0) {
throw new GeneralPlatformDomainRuleException(
"error.msg.current.account.min.required.balance.breached",
"Withdrawal of " + amount + " would breach the minimum required balance of "
+ account.getMinRequiredBalance() + ". Balance after withdrawal would be " + newBalance,
accountId);
}
}
final String refNo = command.stringValueOfParameterNamed("accountNumber");
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
txn.setRunningBalanceDerived(newBalance);
currentAccountTransactionRepository.saveAndFlush(txn);
@@ -10,16 +10,30 @@
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Grant additional READ permissions to the CASHIER role required for
the Cashier Transaction Entry screen to display full account details:
- READ_CLIENTKYC → PAN, Aadhaar, CKYC number
- READ_CLIENTNOMINEE → nominee list per account
- READ_ADDRESS → client address
- READ_FAMILYMEMBERS → S/D/W/H/O (guardian/spouse name)
- READ_SAVINGSACCOUNT → savings account detail fetch
Fix: m_permission.code for READ_STANDINGINSTRUCTION (id=449) had a trailing
space which caused permission checks to fail (exact string match).
Must run BEFORE the permission grant below.
-->
<changeSet id="0315_fix_standing_instruction_permission_code" author="fineract">
<sql>
UPDATE m_permission
SET code = TRIM(code)
WHERE id = 449
AND code != TRIM(code);
</sql>
<rollback><!-- no rollback needed — trimming is idempotent --></rollback>
</changeSet>
<!--
Grant READ permissions to CASHIER role for Cashier Transaction Entry screen:
- READ_CLIENTKYC, READ_CLIENTNOMINEE, READ_ADDRESS, READ_FAMILYMEMBERS
- READ_SAVINGSACCOUNT, READ_CLIENTIMAGE, READ_DOCUMENT
- READ_SAVINGNOTE, READ_CLIENTNOTE
- READ_STANDINGINSTRUCTION (id 449 — trailing space fixed above)
-->
<changeSet id="0315_grant_cashier_transaction_entry_permissions" author="fineract">
<sql>
-- Grant code-based permissions (includes READ_STANDINGINSTRUCTION after trim fix)
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
@@ -28,7 +42,12 @@
'READ_CLIENTNOMINEE',
'READ_ADDRESS',
'READ_FAMILYMEMBERS',
'READ_SAVINGSACCOUNT'
'READ_SAVINGSACCOUNT',
'READ_CLIENTIMAGE',
'READ_DOCUMENT',
'READ_SAVINGNOTE',
'READ_CLIENTNOTE',
'READ_STANDINGINSTRUCTION'
)
WHERE r.name = 'CASHIER'
AND NOT EXISTS (
@@ -42,11 +61,10 @@
AND permission_id IN (
SELECT id FROM m_permission
WHERE code IN (
'READ_CLIENTKYC',
'READ_CLIENTNOMINEE',
'READ_ADDRESS',
'READ_FAMILYMEMBERS',
'READ_SAVINGSACCOUNT'
'READ_CLIENTKYC', 'READ_CLIENTNOMINEE', 'READ_ADDRESS',
'READ_FAMILYMEMBERS', 'READ_SAVINGSACCOUNT', 'READ_CLIENTIMAGE',
'READ_DOCUMENT', 'READ_SAVINGNOTE', 'READ_CLIENTNOTE',
'READ_STANDINGINSTRUCTION'
)
);
</rollback>