From 656a0b4f1363b7f586131fd2ea41a476c18685f5 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Mon, 1 Jun 2026 13:15:52 +0530 Subject: [PATCH] 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 --- .../cashreport/api/CashReportApiResource.java | 83 +++++++++ .../service/DenominationBreakdownWriter.java | 170 ++++++++++++++++++ ...ntTransactionWritePlatformServiceImpl.java | 7 +- ...WritePlatformServiceJpaRepositoryImpl.java | 9 +- ...WritePlatformServiceJpaRepositoryImpl.java | 7 +- 5 files changed, 273 insertions(+), 3 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 2970c9fbf..e25144553 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -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 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. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java index d88f4100f..db9cc1ce9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/denominationmaster/service/DenominationBreakdownWriter.java @@ -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}. + *

+ * 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java index adff716e1..37b22540c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/CurrentAccountTransactionWritePlatformServiceImpl.java @@ -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(), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index db0944fc4..dd1cd5a21 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -362,6 +362,8 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo final Map 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 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(), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index 380ac199b..74bed6cfd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -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 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(),