feat(cashier): add morning transfer validation & fix denomination SQL

- validateCashierHasMorningTransfer(): blocks all txns when no today's cash transfer in m_cash_transfer
- validatePreviousDayClosed(): blocks when previous business day unsettled
- Both validations called on deposit AND withdrawal in Savings, Current, FD, RD services
- cashier-day-status endpoint now returns morningTransferReceived field
- getCashierTotalCash SQL fixed: dm.denomination_value -> dm.denomination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-01 13:15:52 +05:30
co-authored by Claude Sonnet 4.6
parent eae5b03d7e
commit 656a0b4f13
5 changed files with 273 additions and 3 deletions
@@ -14,6 +14,7 @@ import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -25,6 +26,7 @@ public class CashReportApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final Gson gson = new Gson();
@GET
@@ -182,6 +184,87 @@ public class CashReportApiResource {
return gson.toJson(combined);
}
/**
* GET /cashreports/cashier-day-status
* Returns whether the authenticated cashier has an unsettled previous business day.
* Used by the UI to show a warning banner and hard-block withdrawals.
*/
@GET
@Path("cashier-day-status")
public String cashierDayStatus() {
final Map<String, Object> result = new LinkedHashMap<>();
Long appUserId;
try {
appUserId = context.authenticatedUser().getId();
} catch (Exception e) {
result.put("cashierId", null);
result.put("previousDayUnsettled", false);
return gson.toJson(result);
}
Long cashierId;
try {
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, appUserId);
} catch (Exception e) {
// Not a cashier user — no restriction applies
result.put("cashierId", null);
result.put("previousDayUnsettled", false);
return gson.toJson(result);
}
final String today = LocalDate.now().toString();
// Find the earliest transfer date before today for which no settlement exists on or after that date
final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled "
+ "FROM m_cash_transfer ct "
+ "WHERE ct.to_cashier_id = ? "
+ " AND ct.transfer_date < CAST(? AS DATE) "
+ " AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
+ " AND NOT EXISTS ("
+ " SELECT 1 FROM m_cash_settlement cs "
+ " WHERE cs.from_cashier_id = ? "
+ " AND cs.settlement_date >= ct.transfer_date "
+ " AND (cs.is_reversed IS NULL OR cs.is_reversed = false)"
+ " )";
java.sql.Date earliestDate = null;
try {
earliestDate = jdbcTemplate.queryForObject(sql, java.sql.Date.class, cashierId, today, cashierId);
} catch (Exception ignored) {}
final boolean unsettled = earliestDate != null;
final String unsettledSince = earliestDate != null ? earliestDate.toString() : null;
BigDecimal pendingAmount = BigDecimal.ZERO;
if (unsettled) {
try {
pendingAmount = denominationBreakdownWriter.getCashierTotalCash(cashierId);
} catch (Exception ignored) {}
}
// Check whether today's morning cash transfer has been received
final String morningTransferSql = "SELECT COUNT(*) FROM m_cash_transfer ct "
+ "WHERE ct.to_cashier_id = ? "
+ " AND ct.transfer_date = CAST(? AS DATE) "
+ " AND (ct.is_reversed IS NULL OR ct.is_reversed = false)";
Integer todayTransferCount = 0;
try {
todayTransferCount = jdbcTemplate.queryForObject(morningTransferSql, Integer.class, cashierId, today);
} catch (Exception ignored) {}
final boolean morningTransferReceived = todayTransferCount != null && todayTransferCount > 0;
result.put("cashierId", cashierId);
result.put("previousDayUnsettled", unsettled);
result.put("unsettledSince", unsettledSince);
result.put("pendingAmount", pendingAmount);
result.put("morningTransferReceived", morningTransferReceived);
return gson.toJson(result);
}
/**
* GET /cashreports/cashier-position?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise net position per cashier grouped by denomination_id+kind.
@@ -143,6 +143,176 @@ public class DenominationBreakdownWriter {
validateBreakdown(command, expectedAmount, "TRANSACTION");
}
/**
* Blocks any transaction (deposit or withdrawal) when the cashier has an unsettled
* previous business day — i.e., a morning cash transfer exists on a date before today
* with no corresponding settlement on or after that date.
* Admin / non-cashier users are silently skipped.
*/
public void validatePreviousDayClosed() {
Long cashierId = null;
try {
final AppUser user = context.authenticatedUser();
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
} catch (Exception e) {
return; // not a cashier user — skip
}
if (cashierId == null) return;
final String today = java.time.LocalDate.now().toString();
final String sql = "SELECT COUNT(*) FROM m_cash_transfer ct "
+ "WHERE ct.to_cashier_id = ? "
+ " AND ct.transfer_date < CAST(? AS DATE) "
+ " AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
+ " AND NOT EXISTS ("
+ " SELECT 1 FROM m_cash_settlement cs "
+ " WHERE cs.from_cashier_id = ? "
+ " AND cs.settlement_date >= ct.transfer_date "
+ " AND (cs.is_reversed IS NULL OR cs.is_reversed = false)"
+ " )";
final Integer count = jdbcTemplate.queryForObject(sql, Integer.class, cashierId, today, cashierId);
if (count != null && count > 0) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.day.not.closed",
"Previous business day has not been closed. Please complete Cash Settlement and "
+ "Reconciliation before processing any new deposits or withdrawals.");
}
}
/**
* Blocks any transaction (deposit or withdrawal) when the cashier has not received
* a morning cash transfer for today (transfer_date = current date).
* Admin / non-cashier users are silently skipped.
*/
public void validateCashierHasMorningTransfer() {
Long cashierId = null;
try {
final AppUser user = context.authenticatedUser();
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
} catch (Exception e) {
return; // not a cashier user — skip
}
if (cashierId == null) return;
final String today = java.time.LocalDate.now().toString();
final String sql = "SELECT COUNT(*) FROM m_cash_transfer ct "
+ "WHERE ct.to_cashier_id = ? "
+ " AND ct.transfer_date = CAST(? AS DATE) "
+ " AND (ct.is_reversed IS NULL OR ct.is_reversed = false)";
final Integer count = jdbcTemplate.queryForObject(sql, Integer.class, cashierId, today);
if (count == null || count == 0) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.no.morning.transfer",
"No cash transfer received for today. Please request a morning cash transfer "
+ "from the vault before processing any deposits or withdrawals.");
}
}
/**
* Checks that the cashier's total available cash (sum of denomination_value × net_count
* across all denominations) is at least {@code requiredAmount}.
* <p>
* This is a holistic guard that fires even when no denomination breakdown is supplied —
* preventing any withdrawal when the cashier has received no morning cash transfer.
* Admin / non-cashier users are silently skipped.
*
* @param requiredAmount the withdrawal amount that must be covered by the cashier's stock
*/
public void validateCashierHasTotal(final BigDecimal requiredAmount) {
if (requiredAmount == null || requiredAmount.compareTo(BigDecimal.ZERO) <= 0) return;
Long cashierId = null;
try {
final AppUser user = context.authenticatedUser();
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
} catch (Exception e) {
return; // not a cashier user — skip
}
if (cashierId == null) return;
final BigDecimal available = getCashierTotalCash(cashierId);
if (available.compareTo(requiredAmount) < 0) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.stock.insufficient",
"Cashier does not have sufficient cash. Available: ₹" + available
+ ", Required: ₹" + requiredAmount
+ ". Please request a cash transfer from the vault before processing withdrawals.");
}
}
/**
* Returns the total rupee value of all denominations currently held by the cashier
* (vault transfers in + deposits received + exchanges received − settlements out
* − inter-cashier out − withdrawals paid − exchanges given).
*/
public BigDecimal getCashierTotalCash(final Long cashierId) {
final String sql = "SELECT COALESCE(SUM(dm.denomination * t.net_count), 0) "
+ "FROM ("
+ " SELECT denomination_id, SUM(net_count) AS net_count FROM ("
+ " SELECT ctd.denomination_id, CAST(SUM(ctd.count) AS BIGINT) AS net_count"
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
+ " WHERE ct.to_cashier_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false)"
+ " GROUP BY ctd.denomination_id"
+ " UNION ALL"
+ " SELECT icd.denomination_id, CAST(SUM(icd.count) AS BIGINT)"
+ " FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.to_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false)"
+ " GROUP BY icd.denomination_id"
+ " UNION ALL"
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " 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 = ?)"
+ " GROUP BY std.denomination_id"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
+ " FROM m_cash_exchange_detail ced JOIN m_cash_exchange ce ON ce.id = ced.exchange_id"
+ " WHERE ce.cashier_id = ? AND ced.direction = 'RECEIVED'"
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
+ " GROUP BY ced.denomination_id"
+ " UNION ALL"
+ " SELECT csd.denomination_id, CAST(-SUM(csd.count) AS BIGINT)"
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
+ " WHERE cs.from_cashier_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false)"
+ " GROUP BY csd.denomination_id"
+ " UNION ALL"
+ " SELECT icd.denomination_id, CAST(-SUM(icd.count) AS BIGINT)"
+ " FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.from_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false)"
+ " GROUP BY icd.denomination_id"
+ " UNION ALL"
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " 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 = ?)"
+ " GROUP BY std.denomination_id"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
+ " FROM m_cash_exchange_detail ced JOIN m_cash_exchange ce ON ce.id = ced.exchange_id"
+ " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'"
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
+ " GROUP BY ced.denomination_id"
+ " ) raw GROUP BY denomination_id HAVING SUM(net_count) > 0"
+ ") t JOIN m_denomination_master dm ON dm.id = t.denomination_id";
final BigDecimal total = jdbcTemplate.queryForObject(sql, BigDecimal.class,
cashierId, cashierId, cashierId, cashierId, cashierId, cashierId, cashierId, cashierId);
return total != null ? total : BigDecimal.ZERO;
}
/**
* Validates that the cashier currently holds enough of each denomination
* in the withdrawal breakdown. Auto-detects the cashier from the authenticated
@@ -76,6 +76,8 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount");
validatePositiveAmount(amount, accountId, "deposit");
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateBreakdown(command, amount, "DEPOSIT");
final String refNo = command.stringValueOfParameterNamed("accountNumber");
@@ -143,7 +145,10 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(amount));
currentAccountRepository.save(account);
// Validate denomination stock availability then validate totals before saving breakdown
// Validate previous day closed, morning transfer received, total stock, per-denomination, breakdown totals
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateCashierHasTotal(amount);
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, amount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, txn.getId(), account.getId(), account.getAccountNo(),
@@ -362,6 +362,8 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "DEPOSIT");
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail);
@@ -424,6 +426,8 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "DEPOSIT");
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleRDDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail, isRegularTransaction);
@@ -472,7 +476,10 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccountTransaction withdrawal = this.depositAccountDomainService.handleWithdrawal(account, fmt, transactionDate,
transactionAmount, paymentDetail, true, isRegularTransaction);
// Validate denomination stock availability then validate totals before saving breakdown
// Validate previous day closed, morning transfer received, total stock, per-denomination, breakdown totals
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateCashierHasTotal(transactionAmount);
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, withdrawal.getId(), account.getId(), account.getAccountNumber(),
@@ -328,6 +328,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
this.savingsAccountTransactionDataValidator.validateTransactionWithPivotDate(transactionDate, account);
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "DEPOSIT");
final Map<String, Object> changes = new LinkedHashMap<>();
@@ -424,7 +426,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
gsimRepository.save(gsim);
}
// Validate denomination stock availability then validate totals before saving breakdown
// Validate previous day closed, morning transfer received, total stock, per-denomination, breakdown totals
this.denominationBreakdownWriter.validatePreviousDayClosed();
this.denominationBreakdownWriter.validateCashierHasMorningTransfer();
this.denominationBreakdownWriter.validateCashierHasTotal(transactionAmount);
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, withdrawal.getId(), account.getId(), account.getAccountNumber(),