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:
co-authored by
Claude Sonnet 4.6
parent
49d64004cc
commit
eae5b03d7e
+78
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
|
||||
+22
@@ -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()
|
||||
|
||||
+98
-6
@@ -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()
|
||||
|
||||
+117
@@ -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>
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user