From eae5b03d7eed87546e0d1f5b48da8273cb59392e Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Mon, 1 Jun 2026 10:11:44 +0530 Subject: [PATCH] feat(cashier): denomination withdrawal validation, exchange detail fix, cash exchange in excess report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../api/CashExchangeApiResource.java | 79 +++++++++++- .../cashexchange/domain/CashExchange.java | 18 +++ .../api/CashReconciliationApiResource.java | 22 ++++ .../cashreport/api/CashReportApiResource.java | 104 +++++++++++++++- .../service/DenominationBreakdownWriter.java | 117 ++++++++++++++++++ ...ntTransactionWritePlatformServiceImpl.java | 3 +- ...WritePlatformServiceJpaRepositoryImpl.java | 3 +- ...WritePlatformServiceJpaRepositoryImpl.java | 5 +- 8 files changed, 340 insertions(+), 11 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java index c06a5d346..04ac6bea6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/api/CashExchangeApiResource.java @@ -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 stock = denominationBreakdownWriter.getCashierNetPosition(header.getCashierId()); + final java.util.List 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 list = accountId != null ? repository.findByAccountIdOrderByCreatedAtDesc(accountId) @@ -174,15 +245,21 @@ public class CashExchangeApiResource { private Map toMap(CashExchange e) { Map 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/domain/CashExchange.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/domain/CashExchange.java index 586772c5b..175d72f05 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/domain/CashExchange.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashexchange/domain/CashExchange.java @@ -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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java index 05e9e6207..9d8cf0325 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreconciliation/api/CashReconciliationApiResource.java @@ -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 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> rows = new ArrayList<>(); balance.entrySet().stream() 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 ebb53d31e..2970c9fbf 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 @@ -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> 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 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 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 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> result = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> { + final List> savingsRows = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> { Map 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 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> exchangeRows = jdbcTemplate.query(exSql.toString(), (rs, rowNum) -> { + Map 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> 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 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 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> rows = new ArrayList<>(); balance.entrySet().stream() 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 23c4fb3db..d88f4100f 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 @@ -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 stock = getCashierNetPosition(cashierId); + final List 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 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 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. *

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 09b0ec6d5..adff716e1 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 @@ -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, 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 c5190d7c6..db0944fc4 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 @@ -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, 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 0ee80cdeb..380ac199b 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 @@ -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,