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:
co-authored by
Claude Sonnet 4.6
parent
eae5b03d7e
commit
656a0b4f13
+83
@@ -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.
|
||||
|
||||
+170
@@ -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
|
||||
|
||||
+6
-1
@@ -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(),
|
||||
|
||||
+8
-1
@@ -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(),
|
||||
|
||||
+6
-1
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user