feat(cashier): denomination withdrawal validation, exchange detail fix, cash exchange in excess report

- Savings/Current/FD/RD withdrawal services: call validateWithdrawalStock() before
  validateBreakdown() to block withdrawal when cashier lacks the requested denomination
- CashExchangeApiResource: validate cashier denomination stock before giving to customer;
  fix saveAndFlush (EclipseLink does not flush on save() — getId() returned null causing
  exchange_id NOT NULL violation in m_cash_exchange_detail)
- CashReportApiResource excess(): include cash exchanges >= threshold in result,
  add type field (SAVINGS_TXN/EXCHANGE) and idProof for RBI/AML compliance;
  combined list sorted by amount DESC
- CashReportApiResource closing(): add denomination-wise exchange rows (already present)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-01 10:11:44 +05:30
co-authored by Claude Sonnet 4.6
parent 49d64004cc
commit eae5b03d7e
8 changed files with 340 additions and 11 deletions
@@ -24,11 +24,14 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchange;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetail;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetailRepository;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Path("/v1/cashexchange")
@Component
@@ -40,9 +43,12 @@ public class CashExchangeApiResource {
private final CashExchangeRepository repository;
private final CashExchangeDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final Gson gson = new Gson();
@POST
@Transactional
public String create(String body) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
@@ -54,6 +60,8 @@ public class CashExchangeApiResource {
? LocalDate.parse(json.get("exchangeDate").getAsString())
: null;
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
? json.get("customerIdProof").getAsString() : null;
final JsonArray received = json.has("received") && json.get("received").isJsonArray() ? json.getAsJsonArray("received")
: new JsonArray();
@@ -82,17 +90,52 @@ public class CashExchangeApiResource {
header.setExchangeAmount(receivedTotal);
header.setExchangeDate(exchangeDate);
header.setNote(note);
header.setCustomerIdProof(customerIdProof);
header.setIsReversed(false);
header.setCreatedAt(LocalDateTime.now());
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
// Auto-detect cashier linked to this user
try {
Long 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());
header.setCashierId(cashierId);
} catch (Exception ignored) {}
}
} catch (Exception e) {
// resolving the current user must never block recording the exchange
}
CashExchange saved = repository.save(header);
// Validate that cashier has enough of each denomination being GIVEN to the customer
if (header.getCashierId() != null) {
final java.util.Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(header.getCashierId());
final java.util.List<String> shortages = new java.util.ArrayList<>();
for (JsonElement el : given) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) continue;
long denomId = item.get("denominationId").getAsLong();
long available = stock.getOrDefault(denomId, 0L);
if (count > available) {
int denomValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
shortages.add("₹" + denomValue + " (need " + count + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
}
}
CashExchange saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), "RECEIVED", received);
saveDetails(saved.getId(), "GIVEN", given);
@@ -102,6 +145,34 @@ public class CashExchangeApiResource {
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
public String reverse(@PathParam("id") Long id, String body) {
CashExchange exchange = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.already.reversed",
"Cash exchange " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
exchange.setIsReversed(true);
exchange.setReversedAt(LocalDateTime.now());
exchange.setReverseReason(reason);
try {
AppUser user = this.context.authenticatedUser();
if (user != null) exchange.setReversedBy(user.getId());
} catch (Exception ignored) {}
repository.save(exchange);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(@QueryParam("accountId") Long accountId) {
List<CashExchange> list = accountId != null ? repository.findByAccountIdOrderByCreatedAtDesc(accountId)
@@ -174,15 +245,21 @@ public class CashExchangeApiResource {
private Map<String, Object> toMap(CashExchange e) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", e.getId());
map.put("cashierId", e.getCashierId());
map.put("accountId", e.getAccountId());
map.put("accountNo", e.getAccountNo());
map.put("clientId", e.getClientId());
map.put("customerIdProof", e.getCustomerIdProof());
map.put("exchangeAmount", e.getExchangeAmount());
map.put("exchangeDate", e.getExchangeDate() != null ? e.getExchangeDate().toString() : null);
map.put("note", e.getNote());
map.put("performedBy", e.getPerformedBy());
map.put("performedByUsername", e.getPerformedByUsername());
map.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
map.put("isReversed", Boolean.TRUE.equals(e.getIsReversed()));
map.put("reversedBy", e.getReversedBy());
map.put("reversedAt", e.getReversedAt() != null ? e.getReversedAt().toString() : null);
map.put("reverseReason", e.getReverseReason());
return map;
}
@@ -46,6 +46,12 @@ public class CashExchange {
@Column(name = "note", length = 500)
private String note;
@Column(name = "cashier_id")
private Long cashierId;
@Column(name = "customer_id_proof", length = 50)
private String customerIdProof;
@Column(name = "performed_by")
private Long performedBy;
@@ -54,4 +60,16 @@ public class CashExchange {
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "is_reversed")
private Boolean isReversed;
@Column(name = "reversed_by")
private Long reversedBy;
@Column(name = "reversed_at")
private LocalDateTime reversedAt;
@Column(name = "reverse_reason", length = 500)
private String reverseReason;
}
@@ -122,15 +122,37 @@ public class CashReconciliationApiResource {
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
final String sqlIn4 = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ced.count) AS total_count "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.cashier_id = ? AND ced.direction = 'RECEIVED' "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND ce.exchange_date <= CAST(? AS DATE) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind";
// Out: cash exchange — cashier gave denomination TO customer (GIVEN direction)
final String sqlOut4 = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ced.count) AS total_count "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN' "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND ce.exchange_date <= CAST(? AS DATE) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind";
// Balance map: denomination_id → long[3] {denomValue, kindFlag (0=note/1=coin), netCount}
Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query(sqlIn1, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn2, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn3, DENOM_ROW, dateFilter, cashierId), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn4, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlOut1, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut2, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut3, DENOM_ROW, dateFilter, cashierId), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut4, DENOM_ROW, cashierId, dateFilter), -1);
List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream()
@@ -69,12 +69,45 @@ public class CashReportApiResource {
withdrawalTotal = withdrawalTotal.add((BigDecimal) row.get("withdrawalAmount"));
}
// Cash exchange rows — separate section (denomination swap, net monetary = 0)
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
+ "SUM(CASE WHEN ced.direction = 'GIVEN' THEN ced.count ELSE 0 END) AS given_count, "
+ "SUM(CASE WHEN ced.direction = 'GIVEN' THEN ced.total_amount ELSE 0 END) AS given_amount "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.exchange_date = CAST(? AS DATE) "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
+ "ORDER BY ced.denomination_value DESC, dm.kind";
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
long receivedCount = rs.getLong("received_count");
long givenCount = rs.getLong("given_count");
BigDecimal receivedAmount = rs.getBigDecimal("received_amount");
BigDecimal givenAmount = rs.getBigDecimal("given_amount");
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"));
row.put("receivedCount", receivedCount);
row.put("receivedAmount", receivedAmount);
row.put("givenCount", givenCount);
row.put("givenAmount", givenAmount);
row.put("netCount", receivedCount - givenCount);
row.put("netAmount", receivedAmount.subtract(givenAmount));
return row;
}, txnDate);
Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", rows);
result.put("depositTotal", depositTotal);
result.put("withdrawalTotal", withdrawalTotal);
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
result.put("exchangeRows", exchangeRows);
return gson.toJson(result);
}
@@ -83,13 +116,13 @@ public class CashReportApiResource {
public String excess(@QueryParam("threshold") BigDecimal threshold, @QueryParam("fromDate") String fromDate,
@QueryParam("toDate") String toDate) {
final BigDecimal effectiveThreshold = threshold != null ? threshold : BigDecimal.valueOf(50000);
final boolean hasDateRange = fromDate != null && !fromDate.isBlank() && toDate != null && !toDate.isBlank();
// Savings / Current / FD / RD transactions
final StringBuilder sql = new StringBuilder();
sql.append("SELECT txn_id, MAX(account_no) account_no, MAX(client_id) client_id, MAX(cif_no) cif_no, ");
sql.append("MAX(txn_date) txn_date, SUM(total_amount) total_amount FROM m_savings_txn_denomination ");
final List<Object> args = new ArrayList<>();
final boolean hasDateRange = fromDate != null && !fromDate.isBlank() && toDate != null && !toDate.isBlank();
if (hasDateRange) {
sql.append("WHERE txn_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) ");
args.add(fromDate);
@@ -98,19 +131,55 @@ public class CashReportApiResource {
sql.append("GROUP BY txn_id HAVING SUM(total_amount) >= ? ORDER BY total_amount DESC");
args.add(effectiveThreshold);
final List<Map<String, Object>> result = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
final List<Map<String, Object>> savingsRows = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("type", "SAVINGS_TXN");
row.put("txnId", rs.getLong("txn_id"));
row.put("accountNo", rs.getString("account_no"));
row.put("clientId", rs.getObject("client_id"));
row.put("cifNo", rs.getString("cif_no"));
final java.sql.Date txnDate = rs.getDate("txn_date");
row.put("txnDate", txnDate != null ? txnDate.toString() : null);
final java.sql.Date d = rs.getDate("txn_date");
row.put("txnDate", d != null ? d.toString() : null);
row.put("totalAmount", rs.getBigDecimal("total_amount"));
row.put("idProof", null);
return row;
}, args.toArray());
return gson.toJson(result);
// Cash exchange transactions
final StringBuilder exSql = new StringBuilder();
exSql.append("SELECT id, account_no, client_id, exchange_date, exchange_amount, customer_id_proof, performed_by_username ");
exSql.append("FROM m_cash_exchange ");
exSql.append("WHERE (is_reversed IS NULL OR is_reversed = false) AND exchange_amount >= ? ");
final List<Object> exArgs = new ArrayList<>();
exArgs.add(effectiveThreshold);
if (hasDateRange) {
exSql.append("AND exchange_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) ");
exArgs.add(fromDate);
exArgs.add(toDate);
}
exSql.append("ORDER BY exchange_amount DESC");
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exSql.toString(), (rs, rowNum) -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("type", "EXCHANGE");
row.put("txnId", rs.getLong("id"));
row.put("accountNo", rs.getString("account_no"));
row.put("clientId", rs.getObject("client_id"));
row.put("cifNo", null);
final java.sql.Date d = rs.getDate("exchange_date");
row.put("txnDate", d != null ? d.toString() : null);
row.put("totalAmount", rs.getBigDecimal("exchange_amount"));
row.put("idProof", rs.getString("customer_id_proof"));
return row;
}, exArgs.toArray());
// Merge and sort by amount DESC
final List<Map<String, Object>> combined = new ArrayList<>();
combined.addAll(savingsRows);
combined.addAll(exchangeRows);
combined.sort((a, b) -> ((BigDecimal) b.get("totalAmount")).compareTo((BigDecimal) a.get("totalAmount")));
return gson.toJson(combined);
}
/**
@@ -132,6 +201,7 @@ public class CashReportApiResource {
+ " UNION SELECT from_cashier_id FROM m_cash_settlement WHERE from_cashier_id IS NOT NULL "
+ " UNION SELECT from_cashier_id FROM m_cash_inter_cashier WHERE from_cashier_id IS NOT NULL "
+ " UNION SELECT to_cashier_id FROM m_cash_inter_cashier WHERE to_cashier_id IS NOT NULL "
+ " UNION SELECT cashier_id FROM m_cash_exchange WHERE cashier_id IS NOT NULL "
+ ") ids";
List<Long> cashierIds = jdbcTemplate.queryForList(idSql, Long.class);
@@ -279,15 +349,37 @@ public class CashReportApiResource {
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
final String sqlIn4 = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ced.count) AS total_count "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.cashier_id = ? AND ced.direction = 'RECEIVED' "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND ce.exchange_date <= CAST(? AS DATE) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind";
// Exchange OUT: cashier gave denomination to customer (GIVEN direction)
final String sqlOut4 = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ced.count) AS total_count "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN' "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND ce.exchange_date <= CAST(? AS DATE) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind";
// Balance map: denomination_id → long[3] {denomValue, kindFlag (0=note/1=coin), netCount}
Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query(sqlIn1, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn2, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn3, DENOM_ROW, dateFilter, cashierId), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn4, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlOut1, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut2, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut3, DENOM_ROW, dateFilter, cashierId), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut4, DENOM_ROW, cashierId, dateFilter), -1);
List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream()
@@ -11,7 +11,9 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
@@ -22,6 +24,7 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenomination;
import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenominationRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
@@ -34,6 +37,7 @@ public class DenominationBreakdownWriter {
private final SavingsTxnDenominationRepository repository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
// ── Public API ────────────────────────────────────────────────────────────
@@ -139,6 +143,119 @@ public class DenominationBreakdownWriter {
validateBreakdown(command, expectedAmount, "TRANSACTION");
}
/**
* Validates that the cashier currently holds enough of each denomination
* in the withdrawal breakdown. Auto-detects the cashier from the authenticated
* user. If the user is not linked to a cashier (e.g. admin/back-office), the
* check is skipped silently.
*
* @param command the JSON command containing the denominationBreakdown array
*/
public void validateWithdrawalStock(final JsonCommand command) {
if (command == null || !command.parameterExists("denominationBreakdown")) return;
final JsonArray breakdown = command.arrayOfParameterNamed("denominationBreakdown");
if (breakdown == null || breakdown.size() == 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 stock check
}
if (cashierId == null) return;
final Map<Long, Long> stock = getCashierNetPosition(cashierId);
final List<String> shortages = new ArrayList<>();
for (final JsonElement el : breakdown) {
final JsonObject item = el.getAsJsonObject();
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) continue;
final long denomId = item.get("denominationId").getAsLong();
final long available = stock.getOrDefault(denomId, 0L);
if (count > available) {
final int denomValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
shortages.add("₹" + denomValue + " (need " + count + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for withdrawal: " + String.join("; ", shortages));
}
}
/**
* Computes the cashier's current net denomination-wise stock by summing all IN
* transactions and subtracting all OUT transactions for that cashier.
*
* @param cashierId the cashier whose position to compute
* @return map of denominationId → net count available
*/
public Map<Long, Long> getCashierNetPosition(final Long cashierId) {
final String sql = "SELECT denomination_id, SUM(net_count) AS available 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"
+ ") t GROUP BY denomination_id";
final Map<Long, Long> stock = new HashMap<>();
jdbcTemplate.query(sql, rs -> {
stock.put(rs.getLong("denomination_id"), rs.getLong("available"));
}, cashierId, cashierId, cashierId, cashierId, cashierId, cashierId, cashierId, cashierId);
return stock;
}
/**
* Persist each valid denomination row for the given transaction.
* <p>
@@ -143,7 +143,8 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(amount));
currentAccountRepository.save(account);
// Save denomination breakdown for WITHDRAWAL if provided
// Validate denomination stock availability then validate totals before saving breakdown
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, amount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, txn.getId(), account.getId(), account.getAccountNo(),
account.getClient() != null ? account.getClient().getId() : null,
@@ -472,7 +472,8 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccountTransaction withdrawal = this.depositAccountDomainService.handleWithdrawal(account, fmt, transactionDate,
transactionAmount, paymentDetail, true, isRegularTransaction);
// Validate and save denomination breakdown for WITHDRAWAL if provided
// Validate denomination stock availability then validate totals before saving breakdown
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, withdrawal.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
@@ -88,8 +88,8 @@ import org.apache.fineract.portfolio.account.service.AccountAssociationsReadPlat
import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatformService;
import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
import org.apache.fineract.portfolio.group.domain.Group;
@@ -424,7 +424,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
gsimRepository.save(gsim);
}
// Validate and save denomination breakdown for WITHDRAWAL via shared writer
// Validate denomination stock availability then validate totals before saving breakdown
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, withdrawal.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null,