feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and branch position API

- Cash exchange: customer name/mobile/PAN (PAN mandatory above Rs 50,000), denomination validation
  against m_denomination_master, cashier required; Liquibase 0599 adds the three columns.
- Inter-cashier: sender must be the logged-in cashier, cash-allocated validation, new
  cashier-summary endpoint.
- Reconciliation restricted to the logged-in cashier's own records.
- New cashreport/branch-position endpoint (vault + cashier drawers, denomination-wise).
- Customer search returns the KYC PAN.
- Reverse endpoints removed from cash transfer, settlement, exchange and inter-cashier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 18:42:00 +05:30
co-authored by Claude Sonnet 5.5
parent 539f6cd0f4
commit b1eea68b17
10 changed files with 455 additions and 276 deletions
@@ -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<Long, Integer> masterValues = denominationValues();
final List<Leg> receivedLegs = parseLegs("received", received, masterValues);
final List<Leg> 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<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));
// 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<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(cashierId);
final List<String> 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<Long, Integer> denominationValues() {
final Map<Long, Integer> values = new HashMap<>();
for (final Map<String, Object> 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<Leg> parseLegs(final String side, final JsonArray items, final Map<Long, Integer> masterValues) {
final Map<Long, Leg> 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<Leg> 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<Leg> 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());
@@ -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;
@@ -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<Map<String, Object>> 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<String, Object> row = rows.get(0);
final BigDecimal allocated = allocatedAmount(cashierId, asOf);
final Map<String, Object> position = cashReportApiResource.buildPositionForCashier(cashierId, asOf.toString());
final Map<String, Object> 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<Map<String, Object>> heldRows = (List<Map<String, Object>>) position.get("denominations");
if (heldRows == null || heldRows.isEmpty()) {
return;
}
final Map<Long, Long> held = new java.util.HashMap<>();
for (final Map<String, Object> row : heldRows) {
for (final Map<String, Object> row : heldRows == null ? List.<Map<String, Object>>of() : heldRows) {
final Object id = row.get("denominationId");
final Object count = row.get("count");
if (id instanceof Number && count instanceof Number) {
@@ -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<String, Object> map = toMap(header);
map.put("details", detailRepository.findByReconciliationId(id).stream()
.map(this::detailToMap).collect(Collectors.toList()));
@@ -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<String> 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<Long, Map<String, Object>> rows = new LinkedHashMap<>();
for (final Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> lines = new ArrayList<>();
BigDecimal vaultAmount = BigDecimal.ZERO;
BigDecimal cashiersAmount = BigDecimal.ZERO;
for (final Map<String, Object> 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<String, Object> 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<Long, Map<String, Object>> rows, final Map<String, Object> position,
final String field) {
final List<Map<String, Object>> lines = (List<Map<String, Object>>) position.get("denominations");
if (lines == null) {
return;
}
for (final Map<String, Object> line : lines) {
final Object id = line.get("denominationId");
final Object count = line.get("count");
if (!(id instanceof Number) || !(count instanceof Number)) {
continue;
}
final Map<String, Object> 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
@@ -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) {
@@ -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,
@@ -38,14 +38,19 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
private static final Set<String> 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) {
@@ -173,4 +173,5 @@
<include file="parts/0597_enable_recurring_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Cash exchange records who the customer was: an existing client is already linked through client_id,
a walk-in customer through the name and mobile captured at the counter. pan_number holds the PAN the
cashier verified, which is mandatory above Rs 50,000 (Income Tax Rule 114B, followed under RBI
cash-handling guidelines). All three are nullable: exchanges recorded before this change have none.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0599_cash_exchange_add_customer_name" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="customer_name"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="customer_name" type="VARCHAR(150)"/>
</addColumn>
</changeSet>
<changeSet id="0599_cash_exchange_add_customer_mobile" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="customer_mobile"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="customer_mobile" type="VARCHAR(20)"/>
</addColumn>
</changeSet>
<changeSet id="0599_cash_exchange_add_pan_number" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_cash_exchange" columnName="pan_number"/></not>
</preConditions>
<addColumn tableName="m_cash_exchange">
<column name="pan_number" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
</databaseChangeLog>