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 5741531de..504253c9a 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 @@ -16,6 +16,8 @@ import jakarta.ws.rs.core.MediaType; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -29,6 +31,7 @@ import org.apache.fineract.portfolio.denominationmaster.service.DenominationBrea 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.portfolio.client.data.ClientKycApiConstants; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -50,6 +53,12 @@ public class CashExchangeApiResource { private final CashierRoleHelper cashierRoleHelper; private final Gson gson = new Gson(); + /** + * Above this amount the customer's PAN must be recorded: Income Tax Rule 114B requires PAN for cash + * transactions of more than Rs 50,000 in a day, and RBI cash-handling guidelines follow it. + */ + static final BigDecimal PAN_MANDATORY_ABOVE = new BigDecimal("50000"); + @POST @Transactional public String create(String body) { @@ -78,15 +87,25 @@ public class CashExchangeApiResource { 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 String customerName = trimToNull(json, "customerName"); + final String customerMobile = trimToNull(json, "customerMobile"); + final String panInput = trimToNull(json, "panNumber"); + final String panNumber = panInput != null ? panInput.toUpperCase(java.util.Locale.ROOT) : null; final JsonArray received = json.has("received") && json.get("received").isJsonArray() ? json.getAsJsonArray("received") : new JsonArray(); final JsonArray given = json.has("given") && json.get("given").isJsonArray() ? json.getAsJsonArray("given") : new JsonArray(); - final BigDecimal receivedTotal = sumTotal(received); - final BigDecimal givenTotal = sumTotal(given); + // Each line is checked against the denomination master and carries the master's value: the amount + // of an exchange is never taken from a value the caller sent. + final Map masterValues = denominationValues(); + final List receivedLegs = parseLegs("received", received, masterValues); + final List givenLegs = parseLegs("given", given, masterValues); - if (!hasPositiveCount(received) || !hasPositiveCount(given)) { + final BigDecimal receivedTotal = sumTotal(receivedLegs); + final BigDecimal givenTotal = sumTotal(givenLegs); + + if (receivedLegs.isEmpty() || givenLegs.isEmpty()) { throw new PlatformDataIntegrityException("error.msg.cashexchange.empty", "Both received and given denominations are required."); } @@ -99,6 +118,19 @@ public class CashExchangeApiResource { "Both received and given denominations are required."); } + // PAN is checked whenever it is given, and required above Rs 50,000. The screen enforced the + // threshold on its own, through a free-text "PAN / Aadhaar" box, so an API call -- or any + // text in that box -- recorded a large exchange with no PAN behind it. + if (panNumber != null && !panNumber.matches(ClientKycApiConstants.PAN_REGEX)) { + throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.invalid", + "PAN must be 5 letters, 4 digits and 1 letter (e.g. ABCDE1234F).", "panNumber", panNumber); + } + if (receivedTotal.compareTo(PAN_MANDATORY_ABOVE) > 0 && panNumber == null) { + throw new PlatformDataIntegrityException("error.msg.cashexchange.pan.required", + "Customer PAN is required for a cash exchange above Rs 50,000 (amount " + receivedTotal + ").", "panNumber", + receivedTotal); + } + CashExchange header = new CashExchange(); header.setAccountId(accountId); header.setAccountNo(accountNo); @@ -106,7 +138,12 @@ public class CashExchangeApiResource { header.setExchangeAmount(receivedTotal); header.setExchangeDate(exchangeDate); header.setNote(note); - header.setCustomerIdProof(customerIdProof); + // The Excess Transaction (CTR) report reads customer_id_proof, so the verified PAN fills it when + // nothing else was sent. + header.setCustomerIdProof(customerIdProof != null && !customerIdProof.isBlank() ? customerIdProof : panNumber); + header.setCustomerName(customerName); + header.setCustomerMobile(customerMobile); + header.setPanNumber(panNumber); header.setIsReversed(false); header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); try { @@ -114,91 +151,48 @@ public class CashExchangeApiResource { 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) { - // intentionally ignored - } } } catch (Exception e) { - // resolving the current user must never block recording the exchange + // who made the entry is recorded when it can be read } - // 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)); + // An exchange takes notes into one cashier's drawer and hands change out of it, so only the + // cashier behind this login can record it, and it is always theirs. It used to go through with no + // cashier at all when the login was not a cashier's: the entry then belonged to no drawer, moved no + // cashier's position, and skipped the stock check below. + final Long cashierId = cashierRoleHelper.getCurrentCashierId(); + if (cashierId == null) { + throw new PlatformDataIntegrityException("error.msg.cashexchange.cashier.required", + "A cash exchange can only be recorded by a cashier. Your login is not linked to a cashier."); + } + header.setCashierId(cashierId); + + // The cashier has to hold, denomination by denomination, what they hand to the customer. A + // denomination is counted across all its lines (parseLegs merges them): checked line by line, two + // lines of 3 against a stock of 5 each passed although 6 were being given. + final Map stock = denominationBreakdownWriter.getCashierNetPosition(cashierId); + final List shortages = new ArrayList<>(); + for (final Leg leg : givenLegs) { + final long available = stock.getOrDefault(leg.denominationId(), 0L); + if (leg.count() > available) { + shortages.add("₹" + leg.value() + " (need " + leg.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); + saveDetails(saved.getId(), "RECEIVED", receivedLegs); + saveDetails(saved.getId(), "GIVEN", givenLegs); JsonObject result = new JsonObject(); result.addProperty("resourceId", saved.getId()); return gson.toJson(result); } - @POST - @Path("{id}/reverse") - public String reverse(@PathParam("id") Long id, String body) { - // Reversing moves the same cash back, so it is gated the same way REVERSE_CASHTRANSFER and - // REVERSE_CASHSETTLEMENT already are. See create() above and migration 0539. - this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHEXCHANGE"); - - 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) { - // intentionally ignored - } - exchange.setIsReversed(true); - exchange.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - exchange.setReverseReason(reason); - try { - AppUser user = this.context.authenticatedUser(); - if (user != null) exchange.setReversedBy(user.getId()); - } catch (Exception ignored) { - // intentionally 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, @@ -238,52 +232,80 @@ public class CashExchangeApiResource { return gson.toJson(map); } - private BigDecimal sumTotal(JsonArray items) { - BigDecimal total = BigDecimal.ZERO; - for (JsonElement el : items) { - JsonObject item = el.getAsJsonObject(); - int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull() - ? item.get("denominationValue").getAsInt() - : 0; - int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0; - if (count > 0) { - total = total.add(BigDecimal.valueOf((long) value * count)); + /** One checked line of an exchange: a denomination from the master, the master's value for it, and how many. */ + private record Leg(long denominationId, int value, int count) { + } + + /** The value of every denomination in the master (a deleted one is gone; a discontinued one is still there). */ + private Map denominationValues() { + final Map values = new HashMap<>(); + for (final Map row : jdbcTemplate + .queryForList("SELECT id, denomination FROM m_denomination_master WHERE deleted = false")) { + values.put(((Number) row.get("id")).longValue(), ((Number) row.get("denomination")).intValue()); + } + return values; + } + + /** + * The lines of one side with a count, each checked against the denomination master and merged per + * denomination. A line must name a known denomination, and a value the caller sent for it has to be the + * master's: with the caller's value trusted, a request could pass a ₹10 note off as ₹500, make the two + * sides "equal", and shift a drawer's books by the difference. A discontinued denomination is accepted -- + * a customer can still bring withdrawn notes to be exchanged. + */ + private List parseLegs(final String side, final JsonArray items, final Map masterValues) { + final Map merged = new LinkedHashMap<>(); + for (final JsonElement el : items) { + 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()) { + throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.required", + "Every " + side + " line needs a denomination."); + } + final long id = item.get("denominationId").getAsLong(); + final Integer value = masterValues.get(id); + if (value == null) { + throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.unknown", + "Unknown denomination (id " + id + ") in the " + side + " notes."); + } + if (item.has("denominationValue") && !item.get("denominationValue").isJsonNull() + && item.get("denominationValue").getAsInt() != value) { + throw new PlatformDataIntegrityException("error.msg.cashexchange.denomination.value.mismatch", + "Denomination " + id + " is ₹" + value + ", not ₹" + item.get("denominationValue").getAsInt() + "."); + } + merged.merge(id, new Leg(id, value, count), (a, b) -> new Leg(id, value, a.count() + b.count())); + } + return new ArrayList<>(merged.values()); + } + + private BigDecimal sumTotal(final List legs) { + BigDecimal total = BigDecimal.ZERO; + for (final Leg leg : legs) { + total = total.add(BigDecimal.valueOf((long) leg.value() * leg.count())); } return total; } - private boolean hasPositiveCount(JsonArray items) { - for (JsonElement el : items) { - JsonObject item = el.getAsJsonObject(); - int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0; - if (count > 0) { - return true; - } + private static String trimToNull(final JsonObject json, final String key) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return null; } - return false; + final String value = json.get(key).getAsString().trim(); + return value.isEmpty() ? null : value; } - private void saveDetails(Long exchangeId, String direction, JsonArray items) { - for (JsonElement el : items) { - JsonObject item = el.getAsJsonObject(); - int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0; - if (count <= 0) { - continue; - } - int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull() - ? item.get("denominationValue").getAsInt() - : 0; - Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull() - ? item.get("denominationId").getAsLong() - : null; - CashExchangeDetail detail = new CashExchangeDetail(); + private void saveDetails(final Long exchangeId, final String direction, final List legs) { + for (final Leg leg : legs) { + final CashExchangeDetail detail = new CashExchangeDetail(); detail.setExchangeId(exchangeId); detail.setDirection(direction); - detail.setDenominationId(denominationId); - detail.setDenominationValue(value); - detail.setCount(count); - detail.setTotalAmount(BigDecimal.valueOf((long) value * count)); + detail.setDenominationId(leg.denominationId()); + detail.setDenominationValue(leg.value()); + detail.setCount(leg.count()); + detail.setTotalAmount(BigDecimal.valueOf((long) leg.value() * leg.count())); detailRepository.save(detail); } } @@ -296,6 +318,9 @@ public class CashExchangeApiResource { map.put("accountNo", e.getAccountNo()); map.put("clientId", e.getClientId()); map.put("customerIdProof", e.getCustomerIdProof()); + map.put("customerName", e.getCustomerName()); + map.put("customerMobile", e.getCustomerMobile()); + map.put("panNumber", e.getPanNumber()); map.put("exchangeAmount", e.getExchangeAmount()); map.put("exchangeDate", e.getExchangeDate() != null ? e.getExchangeDate().toString() : null); map.put("note", e.getNote()); 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 175d72f05..ea02286b1 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 @@ -52,6 +52,17 @@ public class CashExchange { @Column(name = "customer_id_proof", length = 50) private String customerIdProof; + /** Walk-in customer's name; for an existing client, their display name at the time of the exchange. */ + @Column(name = "customer_name", length = 150) + private String customerName; + + @Column(name = "customer_mobile", length = 20) + private String customerMobile; + + /** PAN verified at the counter; mandatory when the exchange is above Rs 50,000. */ + @Column(name = "pan_number", length = 10) + private String panNumber; + @Column(name = "performed_by") private Long performedBy; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java index 5fccf713a..be17de07f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashintercashier/api/CashInterCashierApiResource.java @@ -74,6 +74,15 @@ public class CashInterCashierApiResource { throw new PlatformDataIntegrityException("error.msg.cashintercashier.same.cashier", "From-cashier and To-cashier cannot be the same."); } + // The cash leaves the sender's drawer, so only the sender can hand it over: the logged-in cashier + // is always the from-cashier. Nobody -- a manager, another cashier -- records a transfer out of + // somebody else's drawer. + final Long ownCashierId = cashierRoleHelper.getCurrentCashierId(); + if (ownCashierId == null || !ownCashierId.equals(fromCashierId)) { + throw new PlatformDataIntegrityException("error.msg.cashintercashier.from.must.be.own", + "An inter-cashier transfer can only be made by the cashier handing the cash over, from their own" + + " drawer."); + } final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull() ? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); @@ -92,6 +101,13 @@ public class CashInterCashierApiResource { "At least one denomination with a positive count is required."); } + // Neither side can take part before the vault has allocated them cash for the day: a cashier + // who never opened their till has no morning allocation behind their drawer, so they can + // neither hand cash on nor take it in. Checked first so that is the message they see, not a + // shortfall on some denomination. + validateCashAllocated(fromCashierId, transferDate, "from"); + validateCashAllocated(toCashierId, transferDate, "to"); + // The sending cashier has to actually hold these notes. Cash transfer validates the vault's // stock and settlement validates the cashier's; this register validated neither, so a cashier // holding five ₹500 notes could hand 200 of them to a colleague. Both sides are then wrong @@ -134,45 +150,6 @@ public class CashInterCashierApiResource { return gson.toJson(result); } - @POST - @Path("{id}/reverse") - @Transactional - public String reverse(@PathParam("id") Long id, String body) { - context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHINTERCASHIER"); - - CashInterCashier record = repository.findById(id) - .orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashintercashier.not.found", - "Inter-cashier transfer not found: " + id)); - if (Boolean.TRUE.equals(record.getIsReversed())) { - throw new PlatformDataIntegrityException("error.msg.cashintercashier.already.reversed", - "Inter-cashier transfer " + id + " is already reversed."); - } - - String reason = null; - try { - JsonObject json = JsonParser.parseString(body).getAsJsonObject(); - reason = json.has("reason") ? json.get("reason").getAsString() : null; - } catch (Exception ignored) { - // intentionally ignored - } - - AppUser user = null; - try { user = this.context.authenticatedUser(); } catch (Exception ignored) { - // intentionally ignored - } - - record.setIsReversed(true); - record.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - record.setReverseReason(reason); - if (user != null) record.setReversedBy(user.getId()); - repository.save(record); - - JsonObject result = new JsonObject(); - result.addProperty("resourceId", id); - result.addProperty("reversed", true); - return gson.toJson(result); - } - @GET public String getAll( @QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset, @@ -209,6 +186,77 @@ public class CashInterCashierApiResource { return gson.toJson(map); } + /** + * One cashier's details, today's vault allocation and denomination-wise cash, so the inter-cashier + * screen can show both sides before the transfer is recorded. The Cashier Position endpoint cannot + * serve this: for a plain cashier it ignores {@code cashierId} and returns their own position, so + * the receiving cashier's figures would never show. + */ + @GET + @Path("cashier-summary") + public String cashierSummary(@QueryParam("cashierId") final Long cashierId, @QueryParam("date") final String date) { + context.authenticatedUser().validateHasReadPermission("CASHINTERCASHIER"); + if (cashierId == null) { + throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.required", "cashierId is required."); + } + final LocalDate asOf = date != null && !date.isBlank() ? LocalDate.parse(date) : DateUtils.getBusinessLocalDate(); + + final List> rows = jdbcTemplate.queryForList(""" + SELECT c.id AS cashier_id, s.display_name AS staff_name, c.teller_id, t.name AS teller_name, + t.office_id, o.name AS office_name + FROM m_cashiers c + LEFT JOIN m_staff s ON s.id = c.staff_id + LEFT JOIN m_tellers t ON t.id = c.teller_id + LEFT JOIN m_office o ON o.id = t.office_id + WHERE c.id = ? + """, cashierId); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.cashintercashier.cashier.not.found", + "Cashier not found: " + cashierId); + } + final Map row = rows.get(0); + final BigDecimal allocated = allocatedAmount(cashierId, asOf); + final Map position = cashReportApiResource.buildPositionForCashier(cashierId, asOf.toString()); + + final Map resp = new LinkedHashMap<>(); + resp.put("cashierId", cashierId); + resp.put("cashierName", row.get("staff_name")); + resp.put("tellerId", row.get("teller_id")); + resp.put("tellerName", row.get("teller_name")); + resp.put("officeId", row.get("office_id")); + resp.put("officeName", row.get("office_name")); + resp.put("asOfDate", asOf.toString()); + resp.put("cashAllocated", allocated.signum() > 0); + resp.put("allocatedAmount", allocated); + resp.put("totalAmount", position.get("totalAmount")); + resp.put("denominations", position.get("denominations")); + return gson.toJson(resp); + } + + /** + * Cash the vault allocated to the cashier on the date, from the same non-reversed + * {@code m_cash_transfer} rows the "No Cash Allocated" gate on the transaction screens counts. + */ + private BigDecimal allocatedAmount(final Long cashierId, final LocalDate date) { + final BigDecimal amount = jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(ct.transfer_amount), 0) FROM m_cash_transfer ct + WHERE ct.to_cashier_id = ? AND ct.transfer_date = CAST(? AS DATE) + AND (ct.is_reversed IS NULL OR ct.is_reversed = false) + """, BigDecimal.class, cashierId, date.toString()); + return amount != null ? amount : BigDecimal.ZERO; + } + + private void validateCashAllocated(final Long cashierId, final LocalDate date, final String side) { + if (allocatedAmount(cashierId, date).signum() > 0) { + return; + } + final String role = "from".equals(side) ? "sending" : "receiving"; + throw new PlatformDataIntegrityException("error.msg.cashintercashier." + side + ".cashier.cash.not.allocated", + "The " + role + " cashier has not been allocated cash from the vault for " + date + + ". Allocate cash to them through Cash Transfer before an inter-cashier transfer.", + side + "CashierId", cashierId); + } + private BigDecimal sumTotal(JsonArray items) { BigDecimal total = BigDecimal.ZERO; for (JsonElement el : items) { @@ -231,8 +279,8 @@ public class CashInterCashierApiResource { * Same check, same source of truth and same message shape as * {@code CashSettlementApiResource.validateCashierHoldsDenominations} -- the Cashier Position * figure, so a cashier cannot hand over notes that report says they are not holding. An empty - * position is left alone rather than refused, matching the settlement path: a cashier with no - * recorded position yet is a setup state, not an overdraft. + * position means the cashier holds nothing: create() only gets here for a cashier the vault has + * allocated cash to, so an empty position is cash already handed on, not a setup state. */ private void validateCashierHoldsDenominations(final Long fromCashierId, final JsonArray denominations, final LocalDate transferDate) { @@ -242,12 +290,9 @@ public class CashInterCashierApiResource { @SuppressWarnings("unchecked") final List> heldRows = (List>) position.get("denominations"); - if (heldRows == null || heldRows.isEmpty()) { - return; - } final Map held = new java.util.HashMap<>(); - for (final Map row : heldRows) { + for (final Map row : heldRows == null ? List.>of() : heldRows) { final Object id = row.get("denominationId"); final Object count = row.get("count"); if (id instanceof Number && count instanceof Number) { 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 954adbd8e..374af1ab8 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 @@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliatio import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail; import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository; import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository; +import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; @@ -46,8 +47,26 @@ public class CashReconciliationApiResource { private final CashReconciliationDetailRepository detailRepository; private final PlatformSecurityContext context; private final JdbcTemplate jdbcTemplate; + private final CashierRoleHelper cashierRoleHelper; private final Gson gson = new Gson(); + /** + * A cashier reconciles only their own drawer. The cashier is picked on the screen, and two cashiers + * under one teller could name each other: one cashier's count then stands in for another's, and the + * reconciliation record is what a settlement is later allowed on the strength of. A user who is not + * a cashier-only login (a manager, the branch head) is not limited. + */ + private void requireOwnCashier(final Long cashierId) { + if (!cashierRoleHelper.isCashierOnly()) { + return; + } + final Long own = cashierRoleHelper.getCurrentCashierId(); + if (own == null || !own.equals(cashierId)) { + throw new PlatformDataIntegrityException("error.msg.cashreconciliation.other.cashier", + "A cashier can only reconcile their own cash."); + } + } + /** * GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd * Returns denomination-wise expected balance for a cashier. @@ -64,6 +83,7 @@ public class CashReconciliationApiResource { throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required", "cashierId query param is required."); } + requireOwnCashier(cashierId); final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); // In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate @@ -227,6 +247,7 @@ public class CashReconciliationApiResource { throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required", "cashierId query param is required."); } + requireOwnCashier(cashierId); final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString(); Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)", @@ -250,6 +271,14 @@ public class CashReconciliationApiResource { throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required", "cashierId is required."); } + // A reconciliation is the cashier's own count of their own drawer. Unlike reading one (a manager + // looks at a cashier's, and day close checks each), recording one is the cashier's alone: neither + // another cashier nor a manager can count a drawer on its owner's behalf. + final Long ownCashierId = cashierRoleHelper.getCurrentCashierId(); + if (ownCashierId == null || !ownCashierId.equals(cashierId)) { + throw new PlatformDataIntegrityException("error.msg.cashreconciliation.own.cashier.only", + "A reconciliation can only be recorded by the cashier whose drawer it is."); + } final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull() ? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull() @@ -338,7 +367,11 @@ public class CashReconciliationApiResource { @GET public String getAll() { + // A cashier sees their own reconciliations only; everyone else sees all of them. + final boolean cashierOnly = cashierRoleHelper.isCashierOnly(); + final Long ownCashierId = cashierOnly ? cashierRoleHelper.getCurrentCashierId() : null; return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream() + .filter(e -> !cashierOnly || (ownCashierId != null && ownCashierId.equals(e.getCashierId()))) .map(this::toMap).collect(Collectors.toList())); } @@ -347,6 +380,7 @@ public class CashReconciliationApiResource { public String getOne(@PathParam("id") Long id) { CashReconciliation header = repository.findById(id) .orElseThrow(() -> new RuntimeException("Cash reconciliation not found: " + id)); + requireOwnCashier(header.getCashierId()); Map map = toMap(header); map.put("details", detailRepository.findByReconciliationId(id).stream() .map(this::detailToMap).collect(Collectors.toList())); 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 20d360fa0..afb0ed8bc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; @@ -1083,6 +1084,137 @@ public class CashReportApiResource { return gson.toJson(vaultPositionService.buildPositionForVault(tellerId, dateFilter)); } + /** + * GET /cashreports/branch-position?officeId=X&asOfDate=yyyy-MM-dd + * + * The notes and coins a branch holds, denomination by denomination: its vault(s) plus every cashier's + * drawer. Cash moving between the vault and a drawer, or from one drawer to another, nets out in this + * total; what changes it is what crosses the counter -- a deposit, a withdrawal, an exchange. An exchange + * (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) is +10 on ₹500 and -50 on ₹100 here, and the + * "exchangeNet" column carries that day's exchange movement beside the total so it can be seen. + * + * Until now no screen showed this total: the vault is untouched by an exchange and each drawer is shown + * alone, so what the branch held per denomination was only known from the hand-entered count at day + * open and close. Not for a cashier -- it spans every drawer; they see their own on Cashier Position. + */ + @GET + @Path("branch-position") + public String branchPosition(@QueryParam("officeId") final Long officeIdParam, @QueryParam("asOfDate") final String asOfDate) { + if (cashierRoleHelper.isCashierOnly()) { + throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.not.for.cashier", + "The branch cash position covers every drawer; a cashier sees their own on the Cashier Position screen."); + } + final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate + : DateUtils.getBusinessLocalDate().toString(); + final Long officeId = officeIdParam != null ? officeIdParam : context.authenticatedUser().getOffice().getId(); + final List officeNames = jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, officeId); + if (officeNames.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.cashreport.branchposition.office.not.found", + "Office not found: " + officeId); + } + + // One row per denomination in the master; a discontinued one (the ₹2000 note) shows only while + // something is still held or moved in it. + final Map> rows = new LinkedHashMap<>(); + for (final Map m : jdbcTemplate + .queryForList("SELECT id, denomination, kind, is_active FROM m_denomination_master WHERE deleted = false")) { + final long id = ((Number) m.get("id")).longValue(); + final Map row = new LinkedHashMap<>(); + row.put("denominationId", id); + row.put("denominationValue", ((Number) m.get("denomination")).intValue()); + row.put("kind", m.get("kind")); + row.put("active", Boolean.TRUE.equals(m.get("is_active"))); + row.put("vaultCount", 0L); + row.put("cashierCount", 0L); + row.put("exchangeNet", 0L); + rows.put(id, row); + } + + boolean vaultHasBreakdown = false; + for (final Long tellerId : jdbcTemplate.queryForList("SELECT id FROM m_tellers WHERE office_id = ? ORDER BY id", Long.class, + officeId)) { + final Map vault = vaultPositionService.buildPositionForVault(tellerId, dateFilter); + vaultHasBreakdown = vaultHasBreakdown || Boolean.TRUE.equals(vault.get("hasBreakdown")); + addPositionCounts(rows, vault, "vaultCount"); + } + for (final Long cashierId : jdbcTemplate.queryForList( + "SELECT mc.id FROM m_cashiers mc JOIN m_tellers t ON t.id = mc.teller_id WHERE t.office_id = ? ORDER BY mc.id", + Long.class, officeId)) { + addPositionCounts(rows, buildPositionForCashier(cashierId, dateFilter), "cashierCount"); + } + + // The day's exchanges across the branch's cashiers: notes taken in less notes given out, per denomination. + for (final Map r : jdbcTemplate.queryForList(""" + SELECT ced.denomination_id, + SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE -ced.count END) AS net + FROM m_cash_exchange_detail ced + JOIN m_cash_exchange ce ON ce.id = ced.exchange_id + JOIN m_cashiers mc ON mc.id = ce.cashier_id + JOIN m_tellers t ON t.id = mc.teller_id + WHERE t.office_id = ? AND ce.exchange_date = CAST(? AS DATE) + AND (ce.is_reversed IS NULL OR ce.is_reversed = false) + GROUP BY ced.denomination_id + """, officeId, dateFilter)) { + final Object id = r.get("denomination_id"); + final Object net = r.get("net"); + if (id instanceof Number && net instanceof Number && rows.containsKey(((Number) id).longValue())) { + rows.get(((Number) id).longValue()).put("exchangeNet", ((Number) net).longValue()); + } + } + + final List> lines = new ArrayList<>(); + BigDecimal vaultAmount = BigDecimal.ZERO; + BigDecimal cashiersAmount = BigDecimal.ZERO; + for (final Map row : rows.values()) { + final long vaultCount = ((Number) row.get("vaultCount")).longValue(); + final long cashierCount = ((Number) row.get("cashierCount")).longValue(); + final long exchangeNet = ((Number) row.get("exchangeNet")).longValue(); + final int value = ((Number) row.get("denominationValue")).intValue(); + if (!Boolean.TRUE.equals(row.get("active")) && vaultCount == 0 && cashierCount == 0 && exchangeNet == 0) { + continue; + } + row.put("totalCount", vaultCount + cashierCount); + row.put("totalAmount", BigDecimal.valueOf(value * (vaultCount + cashierCount))); + vaultAmount = vaultAmount.add(BigDecimal.valueOf(value * vaultCount)); + cashiersAmount = cashiersAmount.add(BigDecimal.valueOf(value * cashierCount)); + lines.add(row); + } + lines.sort((a, b) -> Integer.compare(((Number) b.get("denominationValue")).intValue(), + ((Number) a.get("denominationValue")).intValue())); + + final Map resp = new LinkedHashMap<>(); + resp.put("officeId", officeId); + resp.put("officeName", officeNames.get(0)); + resp.put("asOfDate", dateFilter); + resp.put("vaultHasBreakdown", vaultHasBreakdown); + resp.put("vaultAmount", vaultAmount); + resp.put("cashiersAmount", cashiersAmount); + resp.put("totalAmount", vaultAmount.add(cashiersAmount)); + resp.put("denominations", lines); + return gson.toJson(resp); + } + + /** Adds a position's per-denomination counts (a vault's or a cashier's) into one field of the rows. */ + @SuppressWarnings("unchecked") + private void addPositionCounts(final Map> rows, final Map position, + final String field) { + final List> lines = (List>) position.get("denominations"); + if (lines == null) { + return; + } + for (final Map line : lines) { + final Object id = line.get("denominationId"); + final Object count = line.get("count"); + if (!(id instanceof Number) || !(count instanceof Number)) { + continue; + } + final Map row = rows.get(((Number) id).longValue()); + if (row != null) { + row.merge(field, ((Number) count).longValue(), (a, b) -> ((Number) a).longValue() + ((Number) b).longValue()); + } + } + } + /** * GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD * Returns denomination-wise cash received by the cashier from vault on the given date diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java index 61d6bbab8..5e56fd845 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashsettlement/api/CashSettlementApiResource.java @@ -33,7 +33,6 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail; import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository; import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository; import org.apache.fineract.portfolio.teller.service.GlDispatchService; -import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Component; @@ -181,64 +180,6 @@ public class CashSettlementApiResource { return gson.toJson(result); } - @POST - @Path("{id}/reverse") - @Transactional - public String reverse(@PathParam("id") Long id, String body) { - context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHSETTLEMENT"); - - CashSettlement settlement = repository.findById(id) - .orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashsettlement.not.found", - "Settlement not found: " + id)); - if (Boolean.TRUE.equals(settlement.getIsReversed())) { - throw new PlatformDataIntegrityException("error.msg.cashsettlement.already.reversed", - "Settlement " + id + " is already reversed."); - } - - String reason = null; - try { - JsonObject json = JsonParser.parseString(body).getAsJsonObject(); - reason = json.has("reason") ? json.get("reason").getAsString() : null; - } catch (Exception ignored) { - // intentionally ignored - } - - AppUser user = null; - try { user = this.context.authenticatedUser(); } catch (Exception ignored) { - // intentionally ignored - } - - settlement.setIsReversed(true); - settlement.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - settlement.setReverseReason(reason); - if (user != null) settlement.setReversedBy(user.getId()); - repository.save(settlement); - - if (settlement.getToTellerId() != null && settlement.getFromCashierId() != null) { - try { - Long glTellerId = settlement.getToTellerId(); - try { - Long resolved = jdbcTemplate.queryForObject( - "SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, settlement.getFromCashierId()); - if (resolved != null) glTellerId = resolved; - } catch (Exception ignored) { - // intentionally ignored - } - glDispatchService.allocateCashToCashier(glTellerId, - settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR", - DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), - "Reversal of cash settlement #" + id + (reason != null ? ": " + reason : "")); - } catch (Exception ignored) { - // intentionally ignored - } - } - - JsonObject result = new JsonObject(); - result.addProperty("resourceId", id); - result.addProperty("reversed", true); - return gson.toJson(result); - } - @GET @Path("check") public String check(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index abe44dc8d..437999967 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -35,7 +35,6 @@ import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -160,65 +159,6 @@ public class CashTransferApiResource { return gson.toJson(result); } - @POST - @Path("{id}/reverse") - @Transactional - public String reverse(@PathParam("id") Long id, String body) { - this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER"); - - CashTransfer transfer = repository.findById(id) - .orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashtransfer.not.found", - "Cash transfer not found: " + id)); - if (Boolean.TRUE.equals(transfer.getIsReversed())) { - throw new PlatformDataIntegrityException("error.msg.cashtransfer.already.reversed", - "Cash transfer " + id + " is already reversed."); - } - - String reason = null; - try { - JsonObject json = JsonParser.parseString(body).getAsJsonObject(); - reason = json.has("reason") ? json.get("reason").getAsString() : null; - } catch (Exception ignored) { - // intentionally ignored - } - - try { - AppUser user = this.context.authenticatedUser(); - transfer.setReversedBy(user.getId()); - } catch (Exception ignored) { - // intentionally ignored - } - - transfer.setIsReversed(true); - transfer.setReversedAt(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - transfer.setReverseReason(reason); - repository.save(transfer); - - if (transfer.getFromTellerId() != null && transfer.getToCashierId() != null) { - try { - Long glTellerId = transfer.getFromTellerId(); - try { - Long resolved = jdbcTemplate.queryForObject( - "SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, transfer.getToCashierId()); - if (resolved != null) glTellerId = resolved; - } catch (Exception ignored) { - // intentionally ignored - } - glDispatchService.settleCashFromCashier(glTellerId, - transfer.getToCashierId(), transfer.getTransferAmount(), "INR", - DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), - "Reversal of cash transfer #" + id + (reason != null ? ": " + reason : "")); - } catch (Exception ignored) { - // intentionally ignored - } - } - - JsonObject result = new JsonObject(); - result.addProperty("resourceId", id); - result.addProperty("reversed", true); - return gson.toJson(result); - } - @GET public String getAll( @QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java index 5ed94011b..68a367ce3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/customersearch/service/CustomerSearchReadPlatformServiceImpl.java @@ -38,14 +38,19 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead private static final Set VALID_ACCOUNT_TYPES = Set.of("SAVINGS", "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT"); + // A customer's PAN lives on their KYC record (m_client_kyc, one row per client) and, for some, as a + // "PAN Card" ID proof. The KYC record is what the client screens and cash screens read, so it wins; + // the ID proof is the fallback. Reading only the ID proof showed "PAN -" for customers whose PAN was + // on file, and a search by PAN number found none of them. private static final String BASE_SELECT = """ SELECT DISTINCT c.id AS client_id, c.display_name, c.mobile_no, o.name AS office_name, acct.account_no, acct.account_type, - pan.document_key AS pan_number, aad.document_key AS aadhaar_number, + COALESCE(NULLIF(kyc.pan_number, ''), pan.document_key) AS pan_number, aad.document_key AS aadhaar_number, addr.address_line_1, addr.address_line_2, addr.town_village, addr.city, addr.county_district, addr.postal_code FROM m_client c LEFT JOIN m_office o ON o.id = c.office_id + LEFT JOIN m_client_kyc kyc ON kyc.client_id = c.id LEFT JOIN m_client_identifier pan ON pan.client_id = c.id AND pan.document_type_id = ( SELECT id FROM m_code_value WHERE code_value = 'PAN Card' AND code_id = (SELECT id FROM m_code WHERE code_name = 'KYCIdProofType')) @@ -100,7 +105,8 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead params.add(aadhaarNumber.trim()); } if (hasPan) { - sql.append(" AND UPPER(pan.document_key) = UPPER(?) "); + sql.append(" AND (UPPER(kyc.pan_number) = UPPER(?) OR UPPER(pan.document_key) = UPPER(?)) "); + params.add(panNumber.trim()); params.add(panNumber.trim()); } if (hasAddress) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 7ef1ca7f7..4a2d7b893 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -173,4 +173,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0599_cash_exchange_customer_pan.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0599_cash_exchange_customer_pan.xml new file mode 100644 index 000000000..190adeda9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0599_cash_exchange_customer_pan.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +