From 539f6cd0f423470f77ed6c4627ab4016c36f7369 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 11:00:39 +0530 Subject: [PATCH 1/6] fix(accounting): allow unbalanced legs when defining opening balances Defining opening balances was rejected by the debit/credit balance check that ordinary journal entries go through. For opening balances that check does not hold: every leg is posted against the opening balance contra account, so the books always balance, and the submitted legs legitimately differ by the opening balance of the locked, denomination-managed cash account, which is already posted and can never be part of the request. - validateBusinessRulesForJournalEntries takes a requireBalanced flag. Ordinary journal entries keep the full check (requireBalanced = true). - defineOpeningBalance passes false: it still needs at least one leg and an account and amount on every leg, but no longer requires debits and credits to be present on both sides or to add up. Verified with :fineract-provider:compileJava. Co-Authored-By: Claude Opus 5.5 --- ...WritePlatformServiceJpaRepositoryImpl.java | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java index 93f8d9e80..f3e13716e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryWritePlatformServiceJpaRepositoryImpl.java @@ -687,6 +687,16 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa } private void validateBusinessRulesForJournalEntries(final JournalEntryCommand command) { + validateBusinessRulesForJournalEntries(command, true); + } + + /** + * @param requireBalanced + * false for opening balances: every leg is posted against the contra account, so the books always + * balance, and the submitted legs legitimately differ by the locked (denomination-managed) cash + * account's opening balance that is already posted and can never be part of this request. + */ + private void validateBusinessRulesForJournalEntries(final JournalEntryCommand command, final boolean requireBalanced) { // check if date of Journal entry is valid final LocalDate transactionDate = command.getTransactionDate(); // shouldn't be in the future @@ -706,6 +716,26 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa final SingleDebitOrCreditEntryCommand[] credits = command.getCredits(); final SingleDebitOrCreditEntryCommand[] debits = command.getDebits(); + if (!requireBalanced) { + final List entries = new ArrayList<>(); + if (credits != null) { + entries.addAll(Arrays.asList(credits)); + } + if (debits != null) { + entries.addAll(Arrays.asList(debits)); + } + if (entries.isEmpty()) { + throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.NO_DEBITS_OR_CREDITS, null, null, null); + } + for (final SingleDebitOrCreditEntryCommand entry : entries) { + if (entry.getAmount() == null || entry.getGlAccountId() == null) { + throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.DEBIT_CREDIT_ACCOUNT_OR_AMOUNT_EMPTY, null, null, + null); + } + } + return; + } + // atleast one debit or credit must be present if (credits == null || credits.length == 0 || debits == null || debits.length == 0) { throw new JournalEntryInvalidException(GlJournalEntryInvalidReason.NO_DEBITS_OR_CREDITS, null, null, null); @@ -783,7 +813,7 @@ public class JournalEntryWritePlatformServiceJpaRepositoryImpl implements Journa final Office office = this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); final String currencyCode = command.stringValueOfParameterNamed(JournalEntryJsonInputParams.CURRENCY_CODE.getValue()); - validateBusinessRulesForJournalEntries(journalEntryCommand); + validateBusinessRulesForJournalEntries(journalEntryCommand, false); validateNoDenominationManagedCashAccounts(journalEntryCommand); /** From b1eea68b1739773316e245c7f14cbc7542d72785 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 18:42:00 +0530 Subject: [PATCH 2/6] 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 --- .../api/CashExchangeApiResource.java | 245 ++++++++++-------- .../cashexchange/domain/CashExchange.java | 11 + .../api/CashInterCashierApiResource.java | 135 ++++++---- .../api/CashReconciliationApiResource.java | 34 +++ .../cashreport/api/CashReportApiResource.java | 132 ++++++++++ .../api/CashSettlementApiResource.java | 59 ----- .../api/CashTransferApiResource.java | 60 ----- ...CustomerSearchReadPlatformServiceImpl.java | 10 +- .../tenant/final-changelog-tenant.xml | 1 + .../parts/0599_cash_exchange_customer_pan.xml | 44 ++++ 10 files changed, 455 insertions(+), 276 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0599_cash_exchange_customer_pan.xml 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From da1e21f2cac7152f82f02bc4995663d2c3c51d3a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Tue, 13 Oct 2026 18:42:01 +0530 Subject: [PATCH 3/6] feat(security): block undo/reverse of posted transactions at API level Rejects journal entry reverse, savings/current undo and reverse, fixed and recurring deposit undo, and loan transaction undo (adjust with zero amount) with a clear error, so posted entries cannot be reversed by calling the API directly. Co-Authored-By: Claude Sonnet 5.5 --- .../journalentry/api/JournalEntriesApiResource.java | 6 ++++++ .../loanaccount/api/LoanTransactionsApiResource.java | 5 +++++ .../savings/api/CurrentAccountTransactionsApiResource.java | 6 ++++++ .../api/FixedDepositAccountTransactionsApiResource.java | 6 ++++++ .../RecurringDepositAccountTransactionsApiResource.java | 6 ++++++ .../savings/api/SavingsAccountTransactionsApiResource.java | 7 +++++++ 6 files changed, 36 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/api/JournalEntriesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/api/JournalEntriesApiResource.java index 9521a44d3..34cea53af 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/api/JournalEntriesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/api/JournalEntriesApiResource.java @@ -60,6 +60,7 @@ import org.apache.fineract.infrastructure.core.api.DateParam; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.DateFormat; import org.apache.fineract.infrastructure.core.data.UploadRequest; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; @@ -229,6 +230,11 @@ public class JournalEntriesApiResource { @PathParam("transactionId") @Parameter(description = "transactionId") final String transactionId, @QueryParam("command") @Parameter(description = "command") final String commandParam) { CommandProcessingResult result; + if (is(commandParam, "reverse")) { + throw new PlatformDataIntegrityException("error.msg.journalentry.reverse.not.allowed", + "Reversing a journal entry is not allowed."); + } + if (is(commandParam, "reverse")) { final CommandWrapper commandRequest = new CommandWrapperBuilder().reverseJournalEntry(transactionId).withJson(jsonRequestBody) .build(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java index 112c18b6b..10e0fde10 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java @@ -58,6 +58,7 @@ import org.apache.fineract.infrastructure.core.api.jersey.Pagination; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.DateFormat; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; @@ -737,6 +738,10 @@ public class LoanTransactionsApiResource { commandRequest = builder.buyDownFeeAdjustment(resolvedLoanId, resolvedTransactionId).build(); } else if (CommandParameterUtil.is(commandParam, INTEREST_REFUND_COMMAND_VALUE)) { commandRequest = builder.manualInterestRefund(resolvedLoanId, resolvedTransactionId).build(); + } else if (apiRequestBodyAsJson != null + && apiRequestBodyAsJson.matches("(?s).*\"transactionAmount\"\\s*:\\s*\"?0(\\.0*)?\"?\\s*[,}].*")) { + throw new PlatformDataIntegrityException("error.msg.loan.transaction.undo.not.allowed", + "Undo of a loan transaction is not allowed."); } else { // Default to adjust the Loan Transaction commandRequest = builder.adjustTransaction(resolvedLoanId, resolvedTransactionId).build(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountTransactionsApiResource.java index 8cecd0a1a..741ca37ef 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/CurrentAccountTransactionsApiResource.java @@ -43,6 +43,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; @@ -140,6 +141,11 @@ public class CurrentAccountTransactionsApiResource { @QueryParam("command") final String commandParam, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + if (is(commandParam, "undo") || is(commandParam, "reverse")) { + throw new PlatformDataIntegrityException("error.msg.currentaccount.transaction.undo.not.allowed", + "Undo or reverse of a current account transaction is not allowed."); + } + final CommandWrapperBuilder builder = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson); CommandProcessingResult result = null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountTransactionsApiResource.java index ef1e43358..b2a2d075b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountTransactionsApiResource.java @@ -40,6 +40,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; @@ -148,6 +149,11 @@ public class FixedDepositAccountTransactionsApiResource { @PathParam("transactionId") final Long transactionId, @QueryParam("command") final String commandParam, final String apiRequestBodyAsJson) { + if (is(commandParam, DepositsApiConstants.COMMAND_UNDO_TRANSACTION)) { + throw new PlatformDataIntegrityException("error.msg.fixeddeposit.transaction.undo.not.allowed", + "Undo of a fixed deposit transaction is not allowed."); + } + String jsonApiRequest = apiRequestBodyAsJson; if (StringUtils.isBlank(jsonApiRequest)) { jsonApiRequest = "{}"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositAccountTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositAccountTransactionsApiResource.java index fa071e90b..55afe7947 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositAccountTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositAccountTransactionsApiResource.java @@ -47,6 +47,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; @@ -202,6 +203,11 @@ public class RecurringDepositAccountTransactionsApiResource { @QueryParam("command") @Parameter(description = "command") final String commandParam, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + if (is(commandParam, DepositsApiConstants.COMMAND_UNDO_TRANSACTION)) { + throw new PlatformDataIntegrityException("error.msg.recurringdeposit.transaction.undo.not.allowed", + "Undo of a recurring deposit transaction is not allowed."); + } + String jsonApiRequest = apiRequestBodyAsJson; if (StringUtils.isBlank(jsonApiRequest)) { jsonApiRequest = "{}"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResource.java index b2aff4081..5c7c47bcc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/SavingsAccountTransactionsApiResource.java @@ -48,6 +48,7 @@ import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; @@ -218,6 +219,12 @@ public class SavingsAccountTransactionsApiResource { public String adjustTransaction(@PathParam("savingsId") final Long savingsId, @PathParam("transactionId") final Long transactionId, @QueryParam("command") final String commandParam, final String apiRequestBodyAsJson) { + if (is(commandParam, SavingsApiConstants.COMMAND_UNDO_TRANSACTION) + || is(commandParam, SavingsApiConstants.COMMAND_REVERSE_TRANSACTION)) { + throw new PlatformDataIntegrityException("error.msg.savings.transaction.undo.not.allowed", + "Undo or reverse of a savings transaction is not allowed."); + } + String jsonApiRequest = apiRequestBodyAsJson; if (StringUtils.isBlank(jsonApiRequest)) { jsonApiRequest = "{}"; From 04d1eb20f0a26d3683cf01957e5209f386c7c086 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 14 Oct 2026 13:51:01 +0530 Subject: [PATCH 4/6] feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same maker-checker path as a savings withdrawal: - the request carries gl_account_id, contra_gl_id, office_id and currency_code (new nullable columns, changeset 0600) instead of a savings account; - at submit it is validated: the paying GL must be a mapped cash vault GL and the slip's own GL must not be a cash GL; - at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry; - the cashier pays it against its token, and the denomination and voucher lines are recorded under the journal entry's id. KYC debit checks are skipped for GL requests. Co-Authored-By: Claude Sonnet 5.5 --- .../ClerkWithdrawalRequestApiResource.java | 174 ++++++++++++++++-- .../tenant/final-changelog-tenant.xml | 1 + .../0600_clerk_withdrawal_request_gl.xml | 28 +++ 3 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index d579ec92e..6455ba4b6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -48,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -110,11 +111,20 @@ public class ClerkWithdrawalRequestApiResource { final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root); final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root); final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root); + // A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is + // paid from instead of a savings account; it is approved and disbursed exactly like any other slip. + final boolean isGl = "GL".equalsIgnoreCase(accountType); + final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root); + final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root); + final Long officeId = fromJsonHelper.extractLongNamed("officeId", root); + final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root); if (accountNo == null || accountNo.isBlank()) { throw apiError(400, "Account number is required"); } - if (savingsAccountId == null) { + if (isGl) { + validateGlWithdrawalAccounts(glAccountId, contraGlId); + } else if (savingsAccountId == null) { throw apiError(400, "Savings account id is required for a withdrawal request"); } if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { @@ -124,7 +134,9 @@ public class ClerkWithdrawalRequestApiResource { throw apiError(400, "Token number (TOK. NO.) is required"); } validateTokenUniqueToday(tokNo, null); - kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + if (!isGl) { + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + } final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); @@ -132,8 +144,9 @@ public class ClerkWithdrawalRequestApiResource { INSERT INTO m_clerk_withdrawal_request (account_no, account_type, savings_account_id, client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date, - status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at, + gl_account_id, contra_gl_id, office_id, currency_code) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -142,7 +155,9 @@ public class ClerkWithdrawalRequestApiResource { txnDate != null ? Date.valueOf(txnDate) : null, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - denominationData, activityLog, Timestamp.from(Instant.now()) + denominationData, activityLog, Timestamp.from(Instant.now()), + isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null, + isGl ? (currencyCode != null ? currencyCode : "INR") : null ); // Globally unique voucher number from the central m_voucher register @@ -198,7 +213,8 @@ public class ClerkWithdrawalRequestApiResource { + " txn_date, status, clerk_id, clerk_name, clerk_username," + " manager_id, manager_name, manager_username, rejection_reason, txn_id," + " denomination_data, activity_log, submitted_at, actioned_at," - + " cashier_id, disbursed_by, disbursed_at, voucher_no" + + " cashier_id, disbursed_by, disbursed_at, voucher_no," + + " gl_account_id, contra_gl_id, office_id, currency_code" + " FROM m_clerk_withdrawal_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -252,7 +268,8 @@ public class ClerkWithdrawalRequestApiResource { payment_type_name, description, tok_no, txn_date, status, clerk_name, manager_name, manager_username, rejection_reason, txn_id, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request """ + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray() @@ -276,7 +293,8 @@ public class ClerkWithdrawalRequestApiResource { txn_date, status, clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, denomination_data, activity_log, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -295,7 +313,8 @@ public class ClerkWithdrawalRequestApiResource { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, amount, payment_type_id, payment_type_name, description, tok_no, status, - clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no + clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE ORDER BY submitted_at DESC LIMIT 1 @@ -455,13 +474,16 @@ public class ClerkWithdrawalRequestApiResource { "Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it."); } + final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type")); final Long savingsAccountId = record.get("savings_account_id") != null ? ((Number) record.get("savings_account_id")).longValue() : null; // Checked again on approval, not only on submission: a request can sit in the queue for days, // and the account can cross the dormancy threshold while it waits. The savings withdrawal // below refuses a dormant account anyway, but that failure would surface to the manager as a // rejected transaction after the approval was recorded; this says why, before anything moves. - kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + if (!isGlRequest) { + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + } final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); @@ -469,7 +491,11 @@ public class ClerkWithdrawalRequestApiResource { final String description = (String) record.get("description"); String txnId = null; - if (savingsAccountId != null) { + if (isGlRequest) { + // Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal. + validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id"))); + txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description); + } else if (savingsAccountId != null) { final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no")); txnId = isCurrentAccount ? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description) @@ -477,14 +503,18 @@ public class ClerkWithdrawalRequestApiResource { } // Link voucher to the created savings txn and stamp voucher_no on it - if (txnId != null) { + if (txnId != null && !isGlRequest) { voucherService.linkSavingsTransaction( VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId)); } // The sides of the slip. A withdrawal is money leaving the member, so their account is // debited and the till that pays it out is credited. - addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId); + if (isGlRequest) { + addGlWithdrawalVoucherLines(record, amount); + } else { + addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId); + } final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null @@ -689,8 +719,8 @@ public class ClerkWithdrawalRequestApiResource { if (denomMap == null || denomMap.size() == 0) return; final Object txnIdObj = record.get("txn_id"); - final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null; - if (txnId == null) return; // no savings txn linked — cannot record denomination + final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null; + if (txnId == null) return; // no txn linked — cannot record denomination final Object saObj = record.get("savings_account_id"); final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null; @@ -793,6 +823,117 @@ public class ClerkWithdrawalRequestApiResource { return rows.isEmpty() ? null : rows.get(0); } + private static Long toLong(final Object value) { + return value instanceof Number n ? n.longValue() : null; + } + + /** + * A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one + * Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under + * the id of its own journal entry in the cashier's position and closing report. + */ + private Long parseTxnKey(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Long.valueOf(raw); + } catch (final NumberFormatException ignored) { + try { + final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw; + return Long.parseUnsignedLong(hex, 16); + } catch (final NumberFormatException ignored2) { + return null; + } + } + } + + /** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the + * GL it is paid from must be one of the branch's mapped cash accounts. */ + private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) { + if (glAccountId == null || contraGlId == null) { + throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request"); + } + final List cashGlIds = jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)", + Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue()); + if (!cashGlIds.contains(contraGlId)) { + throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL."); + } + if (cashGlIds.contains(glAccountId)) { + throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip."); + } + } + + /** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is + * switched off for manual postings). Returns the journal entry's transaction id. */ + private String executeGlWithdrawalJournal(final Map record, final BigDecimal amount, + final Object paymentTypeId, final String txnDateStr, final String note) { + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; + + final JsonObject debit = new JsonObject(); + debit.addProperty("glAccountId", toLong(record.get("gl_account_id"))); + debit.addProperty("amount", amount); + final JsonObject credit = new JsonObject(); + credit.addProperty("glAccountId", toLong(record.get("contra_gl_id"))); + credit.addProperty("amount", amount); + final JsonArray debits = new JsonArray(); + debits.add(debit); + final JsonArray credits = new JsonArray(); + credits.add(credit); + + final Object office = record.get("office_id"); + final Object currency = record.get("currency_code"); + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L); + payload.addProperty("transactionDate", date); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR"); + if (note != null && !note.isBlank()) { + payload.addProperty("comments", note); + } + if (paymentTypeId != null) { + payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue()); + } + payload.add("credits", credits); + payload.add("debits", debits); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build(); + // The request raised its own voucher at submission; the journal command must not raise a second. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + return result.getTransactionId(); + } + + /** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */ + private void addGlWithdrawalVoucherLines(final Map record, final BigDecimal amount) { + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + try { + final Long glId = toLong(record.get("gl_account_id")); + final Long contraId = toLong(record.get("contra_gl_id")); + final Map gl = glAccountForVoucher(glId); + final Map contra = glAccountForVoucher(contraId); + voucherService.addLines(voucherNo, List.of( + VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount), + VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount))); + } catch (final RuntimeException e) { + LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e); + } + } + + private Map glAccountForVoucher(final Long glAccountId) { + if (glAccountId == null) { + return Map.of(); + } + return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, @@ -800,7 +941,8 @@ public class ClerkWithdrawalRequestApiResource { txn_date, status, clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, denomination_data, activity_log, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE id = ? """, id); if (rows.isEmpty()) { 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 4a2d7b893..53762ea3e 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 @@ -174,4 +174,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml new file mode 100644 index 000000000..ec5d99201 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + From b47a34b5ef103bd9b3ee256535c3e93928eff1b2 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:27:21 +0530 Subject: [PATCH 5/6] feat(vouchers): voucher correction for cash deposits posted to the wrong account A cash deposit posted to the wrong savings account is corrected without ever undoing it behind anyone's back: the voucher is unpassed (admin or clerk), a manager approves, the cashier enters the correct account, and a manager approves again. Only the final approval moves money: the deposit is reversed on the wrong account, the same cash is deposited into the right one under a new voucher, and the cashier's denominations follow it. - VoucherCorrectionApiResource: eligible list, unpass, approve/reject unpass, account lookup, correction, approve/reject correction, close request, voided transactions. - The wrong account's balance is checked when the cashier enters the correction and again at approval. - A cashier whose customer has already used the money closes the request and deposits separately. - Roles are enforced on the server: unpass for admin/clerk, correction and close for a cashier, approvals for a manager or head cashier other than the maker. - Migration 0601 adds m_voucher_correction; 0602 drops the status columns an earlier draft put on m_voucher, since the correction status is read from m_voucher_correction alone. - Voucher registers (register, My Vouchers, day book, voucher detail) report each voucher's correction status and leave a revoked voucher out of their totals, so its cash is not counted twice. Co-Authored-By: Claude Sonnet 5.5 --- .../cashreport/api/CashReportApiResource.java | 15 +- .../api/VoucherCorrectionApiResource.java | 623 ++++++++++++++++++ .../api/VoucherVerificationApiResource.java | 26 +- .../voucher/service/VoucherService.java | 60 ++ .../tenant/final-changelog-tenant.xml | 2 + .../tenant/parts/0601_voucher_correction.xml | 75 +++ .../0602_voucher_drop_status_columns.xml | 24 + 7 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml 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 afb0ed8bc..ca448a2c8 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 @@ -20,6 +20,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -36,6 +37,7 @@ public class CashReportApiResource { private final DenominationBreakdownWriter denominationBreakdownWriter; private final CashierRoleHelper cashierRoleHelper; private final VaultPositionService vaultPositionService; + private final VoucherService voucherService; private final Gson gson = new Gson(); /** @@ -329,7 +331,18 @@ public class CashReportApiResource { BigDecimal priorCreditTotal = BigDecimal.ZERO; BigDecimal priorDebitTotal = BigDecimal.ZERO; + // Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on + // the voucher that replaced it, so it must not be counted in the day's totals a second time. + final Map> marks = voucherService.voucherMarks( + rows.stream().map(r -> (String) r.get("voucherNo")).toList()); + for (final Map row : rows) { + final Map mark = marks.get((String) row.get("voucherNo")); + row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus")); + row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo")); + row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo")); + row.put("correctionNote", mark == null ? null : mark.get("correctionNote")); + final boolean revoked = "REVOKED".equals(row.get("voucherStatus")); if (sideFilter != null && !sideFilter.equals(row.get("side"))) { continue; } @@ -350,7 +363,7 @@ public class CashReportApiResource { // was already earned, and the day it belongs to has been counted and signed off. Adding // it to this day's totals would make them disagree with the ledger for either date. final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section")); - if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) + if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) { final BigDecimal amount = (BigDecimal) row.get("amount"); if (amount != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java new file mode 100644 index 000000000..d153d8588 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java @@ -0,0 +1,623 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.voucher.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +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.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Corrects a cash deposit that went to the wrong savings account, without ever "undoing" it behind the + * cashier's back. + * + *
    + *
  1. The cashier picks the day's voucher and asks to unpass it (UNPASS_PENDING). Nothing moves.
  2. + *
  3. A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply + * waiting for its correction entry.
  4. + *
  5. The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number + * (CORRECTION_PENDING).
  6. + *
  7. A manager approves the correction (COMPLETED). Only now do accounts change: the original deposit is + * reversed on the wrong account and the same cash is deposited into the right one, under a new voucher; the + * original voucher is revoked and the cashier's denominations follow the deposit.
  8. + *
+ * + * The balance of the wrong account is checked twice, when the cashier enters the correction and again when it is + * approved, because the customer may have drawn the money in between. + */ +@Path("/v1/vouchers/corrections") +@Component +@Tag(name = "Voucher Corrections", description = "Unpass a wrongly-posted cash deposit voucher and re-post it to the right account.") +@RequiredArgsConstructor +public class VoucherCorrectionApiResource { + + public static final String REF_VOUCHER_CORRECTION = "VOUCHER_CORRECTION"; + + private static final String UNPASS_PENDING = "UNPASS_PENDING"; + private static final String UNPASSED = "UNPASSED"; + private static final String UNPASS_REJECTED = "UNPASS_REJECTED"; + private static final String CORRECTION_PENDING = "CORRECTION_PENDING"; + private static final String COMPLETED = "COMPLETED"; + private static final String CLOSED = "CLOSED"; + private static final int SAVINGS_STATUS_ACTIVE = 300; + private static final int DEPOSIT_TYPE_SAVINGS = 100; + + private static final DateTimeFormatter VOUCHER_DAY = DateTimeFormatter.ofPattern("ddMMyyyy"); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; + private final KycRestrictionGuard kycRestrictionGuard; + private final CashierRoleHelper cashierRoleHelper; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ── Today's vouchers the cashier may unpass ────────────────────────────────── + + /** Today's cash deposit vouchers on a savings account that have not been reversed, revoked or taken up already. */ + @GET + @Path("eligible") + @Produces(MediaType.APPLICATION_JSON) + public String eligible(@QueryParam("search") final String search) { + requireUnpasser(this.context.authenticatedUser()); + final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY); + final List params = new ArrayList<>(); + params.add("%/" + day + "/%"); + final StringBuilder sql = new StringBuilder(ELIGIBLE_SQL); + if (search != null && !search.isBlank()) { + sql.append(" AND (v.voucher_no ILIKE ? OR sa.account_no ILIKE ? OR c.display_name ILIKE ?)"); + final String like = "%" + search.trim() + "%"; + params.add(like); + params.add(like); + params.add(like); + } + sql.append(" ORDER BY v.id DESC"); + return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray())); + } + + private static final String ELIGIBLE_SQL = """ + SELECT v.voucher_no AS "voucherNo", v.amount, v.created_at AS "createdAt", + v.created_by_username AS "cashier", v.savings_txn_id AS "txnId", + t.savings_account_id AS "savingsAccountId", sa.account_no AS "accountNo", + c.display_name AS "clientName", pt.value AS "paymentType" + FROM m_voucher v + JOIN m_savings_account_transaction t ON t.id = v.savings_txn_id + JOIN m_savings_account sa ON sa.id = t.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + JOIN m_payment_detail pd ON pd.id = t.payment_detail_id + JOIN m_payment_type pt ON pt.id = pd.payment_type_id + WHERE v.voucher_type = 'DEPOSIT' + AND COALESCE(v.reference_type, '') <> 'VOUCHER_CORRECTION' + AND v.voucher_no LIKE ? + AND t.is_reversed = false + AND t.transaction_type_enum = 1 + AND pt.is_cash_payment = true + AND sa.deposit_type_enum = 100 + AND NOT EXISTS (SELECT 1 FROM m_voucher_correction vc + WHERE vc.original_voucher_no = v.voucher_no + AND vc.status NOT IN ('UNPASS_REJECTED')) + """; + + // ── Step 1: cashier unpasses a voucher ─────────────────────────────────────── + + @POST + @Path("unpass") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String unpass(final String body) { + final AppUser user = this.context.authenticatedUser(); + requireUnpasser(user); + final JsonElement root = fromJsonHelper.parse(body); + final String voucherNo = fromJsonHelper.extractStringNamed("voucherNo", root); + final String reason = fromJsonHelper.extractStringNamed("reason", root); + if (voucherNo == null || voucherNo.isBlank()) { + throw apiError(400, "Voucher number is required"); + } + if (reason == null || reason.isBlank()) { + throw apiError(400, "Reason is required"); + } + + final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY); + final List> rows = jdbcTemplate.queryForList(ELIGIBLE_SQL + " AND v.voucher_no = ?", "%/" + day + "/%", + voucherNo.trim()); + if (rows.isEmpty()) { + throw apiError(409, "Voucher " + voucherNo + + " cannot be unpassed: only today's cash deposit vouchers on a savings account that are still active qualify."); + } + final Map v = rows.get(0); + + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_voucher_correction + (original_voucher_no, original_txn_id, wrong_savings_account_id, wrong_account_no, wrong_client_name, + amount, payment_type_id, txn_date, status, reason, unpass_by_id, unpass_by_name, unpass_at, activity_log) + VALUES (?,?,?,?,?,?,(SELECT pd.payment_type_id FROM m_savings_account_transaction t + JOIN m_payment_detail pd ON pd.id = t.payment_detail_id WHERE t.id = ?), + (SELECT transaction_date FROM m_savings_account_transaction WHERE id = ?), + ?,?,?,?,?,?) + RETURNING id + """, Long.class, voucherNo.trim(), v.get("txnId"), v.get("savingsAccountId"), v.get("accountNo"), v.get("clientName"), + v.get("amount"), v.get("txnId"), v.get("txnId"), UNPASS_PENDING, reason.trim(), user.getId(), user.getDisplayName(), + Timestamp.from(Instant.now()), log(null, "UNPASS REQUESTED", user, reason.trim())); + return gson.toJson(Map.of("resourceId", id, "status", UNPASS_PENDING)); + } + + // ── Lists ──────────────────────────────────────────────────────────────────── + + /** + * Corrections by status. {@code status} may be a comma-separated list. The approval page asks for + * UNPASS_PENDING and CORRECTION_PENDING, the cashier's page for UNPASSED. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public String list(@QueryParam("status") final String status, @QueryParam("mine") @DefaultValue("false") final boolean mine) { + final AppUser user = this.context.authenticatedUser(); + final StringBuilder sql = new StringBuilder("SELECT * FROM m_voucher_correction WHERE 1=1"); + final List params = new ArrayList<>(); + if (status != null && !status.isBlank()) { + final String[] statuses = status.split(","); + sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")"); + for (final String s : statuses) { + params.add(s.trim().toUpperCase()); + } + } + if (mine) { + sql.append(" AND (unpass_by_id = ? OR correction_by_id = ?)"); + params.add(user.getId()); + params.add(user.getId()); + } + sql.append(" ORDER BY id DESC LIMIT 200"); + return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray())); + } + + @GET + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public String get(@PathParam("id") final Long id) { + this.context.authenticatedUser(); + return gson.toJson(load(id)); + } + + // ── Step 2: manager approves / rejects the unpass ──────────────────────────── + + @POST + @Path("{id}/unpass/approve") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String approveUnpass(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, UNPASS_PENDING, "approved"); + requireNotMaker(user, r.get("unpass_by_id"), "unpass request"); + final int updated = jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, unpass_approved_by_id = ?, unpass_approved_by_name = ?, + unpass_approved_at = ?, activity_log = ? + WHERE id = ? AND status = ? + """, UNPASSED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), + log((String) r.get("activity_log"), "UNPASS APPROVED", user, null), id, UNPASS_PENDING); + if (updated == 0) { + throw apiError(409, "This request was already actioned by another manager."); + } + // Deliberately no account movement here: the deposit stays exactly where it is until the correction is approved. + return gson.toJson(Map.of("resourceId", id, "status", UNPASSED)); + } + + @POST + @Path("{id}/unpass/reject") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String rejectUnpass(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, UNPASS_PENDING, "rejected"); + final String reason = requireReason(body); + jdbcTemplate.update("UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ?", UNPASS_REJECTED, + reason, log((String) r.get("activity_log"), "UNPASS REJECTED", user, reason), id); + return gson.toJson(Map.of("resourceId", id, "status", UNPASS_REJECTED)); + } + + // ── Step 3: cashier enters the correct account ─────────────────────────────── + + /** Resolves the account number the cashier typed: name, balance and whether it can receive the deposit. */ + @GET + @Path("account-lookup") + @Produces(MediaType.APPLICATION_JSON) + public String accountLookup(@QueryParam("accountNo") final String accountNo) { + this.context.authenticatedUser(); + final Map account = findSavingsAccount(accountNo); + if (account == null) { + throw apiError(404, "No savings account found for '" + accountNo + "'."); + } + return gson.toJson(account); + } + + @POST + @Path("{id}/correction") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String submitCorrection(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + // The corrected entry is the cashier's own: a deposit is taken at the counter, so only a cashier can re-enter it. + if (cashierRoleHelper.getCurrentCashierId() == null) { + throw apiError(403, "Only a cashier can enter the correction for a voucher. Your login is not linked to a cashier."); + } + final Map r = load(id); + requireStatus(r, UNPASSED, "corrected"); + final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body)); + final Map correct = validateCorrectAccount(r, accountNo); + checkWrongAccountBalance(r); + + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, correct_savings_account_id = ?, correct_account_no = ?, + correct_client_id = ?, correct_client_name = ?, correction_by_id = ?, correction_by_name = ?, + correction_at = ?, rejection_reason = NULL, activity_log = ? + WHERE id = ? AND status = ? + """, CORRECTION_PENDING, correct.get("id"), correct.get("accountNo"), correct.get("clientId"), correct.get("clientName"), + user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), + log((String) r.get("activity_log"), "CORRECTION SUBMITTED", user, "to " + correct.get("accountNo")), id, UNPASSED); + return gson.toJson(Map.of("resourceId", id, "status", CORRECTION_PENDING)); + } + + // ── Step 4: manager approves / rejects the correction ──────────────────────── + + @POST + @Path("{id}/correction/approve") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String approveCorrection(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, CORRECTION_PENDING, "approved"); + requireNotMaker(user, r.get("correction_by_id"), "correction"); + + // Both sides are checked again: a lot can happen between the cashier saving and the manager approving. + validateCorrectAccount(r, (String) r.get("correct_account_no")); + checkWrongAccountBalance(r); + + final Long wrongId = toLong(r.get("wrong_savings_account_id")); + final Long correctId = toLong(r.get("correct_savings_account_id")); + final Long origTxnId = toLong(r.get("original_txn_id")); + final BigDecimal amount = new BigDecimal(r.get("amount").toString()); + final String origVoucher = (String) r.get("original_voucher_no"); + + final Integer stillLive = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_transaction WHERE id = ? AND savings_account_id = ? AND is_reversed = false", + Integer.class, origTxnId, wrongId); + if (stillLive == null || stillLive == 0) { + throw apiError(409, "The original deposit has already been reversed; this voucher can no longer be corrected."); + } + + // 1. Take the deposit off the wrong account, 2. put the same cash into the right one. + final JsonObject undoPayload = new JsonObject(); + voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource( + new CommandWrapperBuilder().undoSavingsAccountTransaction(wrongId, origTxnId).withJson(undoPayload.toString()).build())); + + final String newTxnId = depositInto(correctId, amount, r); + final Long newTxnKey = Long.valueOf(newTxnId); + + // 3. A new voucher for the corrected entry, in the name of the cashier who made it, with the same till side. + final String newVoucher = voucherService.createVoucher(VoucherService.TYPE_DEPOSIT, REF_VOUCHER_CORRECTION, id, amount, user); + jdbcTemplate.update("UPDATE m_voucher SET created_by = ?, created_by_username = (SELECT username FROM m_appuser WHERE id = ?) WHERE voucher_no = ?", + r.get("correction_by_id"), r.get("correction_by_id"), newVoucher); + voucherService.linkSavingsTransaction(REF_VOUCHER_CORRECTION, id, newTxnKey); + addNewVoucherLines(newVoucher, origVoucher, correctId, (String) r.get("correct_client_name"), (String) r.get("correct_account_no"), + amount); + + // 4. The original voucher is not touched. That it is revoked, and by which voucher, is recorded on this + // correction (original_voucher_no / new_voucher_no) and read from there by the registers. + + // 5. The notes the cashier took in follow the deposit to its new transaction and account. + jdbcTemplate.update(""" + UPDATE m_savings_txn_denomination SET txn_id = ?, savings_account_id = ?, account_no = ?, client_id = ? + WHERE txn_id = ? AND savings_account_id = ? + """, newTxnKey, correctId, r.get("correct_account_no"), r.get("correct_client_id"), origTxnId, wrongId); + + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, correction_approved_by_id = ?, correction_approved_by_name = ?, + correction_approved_at = ?, new_voucher_no = ?, new_txn_id = ?, activity_log = ? + WHERE id = ? AND status = ? + """, COMPLETED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), newVoucher, newTxnKey, + log((String) r.get("activity_log"), "CORRECTION APPROVED", user, "new voucher " + newVoucher), id, CORRECTION_PENDING); + + return gson.toJson(Map.of("resourceId", id, "status", COMPLETED, "newVoucherNo", newVoucher)); + } + + /** A rejected correction goes back to the cashier as UNPASSED, to enter the account again. */ + @POST + @Path("{id}/correction/reject") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String rejectCorrection(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, CORRECTION_PENDING, "rejected"); + final String reason = requireReason(body); + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, correct_savings_account_id = NULL, + correct_account_no = NULL, correct_client_id = NULL, correct_client_name = NULL, activity_log = ? + WHERE id = ? + """, UNPASSED, reason, log((String) r.get("activity_log"), "CORRECTION REJECTED", user, reason), id); + return gson.toJson(Map.of("resourceId", id, "status", UNPASSED)); + } + + /** + * The cashier gives up the correction. Used when the wrong account no longer holds the money (the customer has + * already drawn it), so it cannot be taken back: the cashier then puts the amount into the right account from + * their own pocket as a fresh deposit, and this request is closed. No account moves here, and the original voucher + * stays as it was. + */ + @POST + @Path("{id}/close") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String close(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + if (cashierRoleHelper.getCurrentCashierId() == null) { + throw apiError(403, "Only a cashier can close a voucher correction."); + } + final Map r = load(id); + requireStatus(r, UNPASSED, "closed"); + final String reason = requireReason(body); + final int updated = jdbcTemplate.update( + "UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ? AND status = ?", CLOSED, + reason, log((String) r.get("activity_log"), "CLOSED BY CASHIER", user, reason), id, UNPASSED); + if (updated == 0) { + throw apiError(409, "This request was already actioned."); + } + return gson.toJson(Map.of("resourceId", id, "status", CLOSED)); + } + + // ── For the passbook and transaction list of the wrong account ─────────────── + + /** Transactions of one account that were removed by a completed correction, so its screens can leave them out. */ + @GET + @Path("voided-transactions") + @Produces(MediaType.APPLICATION_JSON) + public String voidedTransactions(@QueryParam("savingsAccountId") final Long savingsAccountId) { + this.context.authenticatedUser(); + final JsonArray ids = new JsonArray(); + jdbcTemplate.queryForList( + "SELECT original_txn_id FROM m_voucher_correction WHERE status = ? AND wrong_savings_account_id = ?", Long.class, COMPLETED, + savingsAccountId).forEach(ids::add); + return gson.toJson(ids); + } + + // ── helpers ────────────────────────────────────────────────────────────────── + + /** Unpassing a voucher is for the back office: an administrator or a clerk. The cashier does not unpass, only re-enters. */ + private void requireUnpasser(final AppUser user) { + final boolean admin = user.hasAnyPermission("ALL_FUNCTIONS"); + final boolean clerk = user.getRoles().stream().anyMatch(role -> role.getName() != null && "clerk".equals(role.getName().toLowerCase().trim())); + if (!admin && !clerk) { + throw apiError(403, "Only an administrator or a clerk can unpass a voucher."); + } + } + + private Map validateCorrectAccount(final Map r, final String accountNo) { + if (accountNo == null || accountNo.isBlank()) { + throw apiError(400, "The correct account number is required"); + } + final Map correct = findSavingsAccount(accountNo); + if (correct == null) { + throw apiError(404, "No savings account found for '" + accountNo + "'."); + } + if (toLong(correct.get("id")).equals(toLong(r.get("wrong_savings_account_id")))) { + throw apiError(400, "The correct account is the same account the deposit was posted to."); + } + if (!Boolean.TRUE.equals(correct.get("canReceive"))) { + throw apiError(409, "Account " + correct.get("accountNo") + " is not active, so it cannot receive this deposit."); + } + final Object wrongCurrency = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class, + r.get("wrong_savings_account_id")).stream().findFirst().orElse(null); + if (wrongCurrency != null && !wrongCurrency.equals(correct.get("currencyCode"))) { + throw apiError(409, "The correct account is in a different currency."); + } + kycRestrictionGuard.check(toLong(correct.get("id")), KycRestrictionGuard.Operation.CREDIT); + return correct; + } + + /** The wrong account must still hold at least this deposit, or taking it back would overdraw it. */ + private void checkWrongAccountBalance(final Map r) { + final BigDecimal balance = jdbcTemplate.queryForObject("SELECT account_balance_derived FROM m_savings_account WHERE id = ?", + BigDecimal.class, r.get("wrong_savings_account_id")); + final BigDecimal amount = new BigDecimal(r.get("amount").toString()); + if (balance == null || balance.compareTo(amount) < 0) { + throw apiError(409, "Account " + r.get("wrong_account_no") + " no longer holds ₹" + amount + " (balance ₹" + + (balance == null ? "0" : balance) + "), so the deposit cannot be taken back. The customer may have used the money."); + } + } + + private Map findSavingsAccount(final String accountNo) { + if (accountNo == null || accountNo.isBlank()) { + return null; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no AS "accountNo", sa.client_id AS "clientId", c.display_name AS "clientName", + sa.account_balance_derived AS "balance", sa.currency_code AS "currencyCode", + (sa.status_enum = ? AND sa.deposit_type_enum = ?) AS "canReceive" + FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id + WHERE sa.account_no = ? + """, SAVINGS_STATUS_ACTIVE, DEPOSIT_TYPE_SAVINGS, accountNo.trim()); + return rows.isEmpty() ? null : rows.get(0); + } + + private String depositInto(final Long savingsAccountId, final BigDecimal amount, final Map r) { + final Object txnDate = r.get("txn_date"); + final String date = txnDate == null ? DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) + : LocalDate.parse(txnDate.toString().substring(0, 10)).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", date); + payload.addProperty("transactionAmount", amount); + if (r.get("payment_type_id") != null) { + payload.addProperty("paymentTypeId", ((Number) r.get("payment_type_id")).longValue()); + } + payload.addProperty("note", "Correction of voucher " + r.get("original_voucher_no")); + final CommandWrapper command = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()) + .build(); + final CommandProcessingResult result = voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(command)); + if (result.getResourceId() == null) { + throw apiError(500, "The corrected deposit could not be posted."); + } + return result.getResourceId().toString(); + } + + /** The new voucher keeps the original's till side and names the right account on the credit side. */ + private void addNewVoucherLines(final String newVoucher, final String origVoucher, final Long correctId, final String clientName, + final String accountNo, final BigDecimal amount) { + try { + final List lines = new ArrayList<>(); + for (final Map l : jdbcTemplate.queryForList( + "SELECT gl_account_id, party_name, account_no FROM m_voucher_line WHERE voucher_no = ? AND entry_type = 'DR' ORDER BY line_no", + origVoucher)) { + lines.add(VoucherLine.debitGl(toLong(l.get("gl_account_id")), (String) l.get("party_name"), (String) l.get("account_no"), + amount)); + } + if (lines.isEmpty()) { + lines.add(VoucherLine.debitGl(null, "Cash in Hand", null, amount)); + } + lines.add(VoucherLine.creditSavings(correctId, clientName, accountNo, amount)); + voucherService.addLines(newVoucher, lines); + } catch (final RuntimeException e) { + // The deposit is already posted; a voucher without its sides is better than undoing a correct posting. + org.slf4j.LoggerFactory.getLogger(VoucherCorrectionApiResource.class) + .error("Could not record the sides of correction voucher {}", newVoucher, e); + } + } + + private Map load(final Long id) { + final List> rows = jdbcTemplate.queryForList("SELECT * FROM m_voucher_correction WHERE id = ?", id); + if (rows.isEmpty()) { + throw new NotFoundException("Voucher correction not found: " + id); + } + return rows.get(0); + } + + private void requireStatus(final Map r, final String expected, final String action) { + if (!expected.equals(r.get("status"))) { + throw apiError(409, "Only " + expected + " requests can be " + action + ". Current status: " + r.get("status")); + } + } + + private void requireNotMaker(final AppUser user, final Object makerId, final String what) { + if (makerId != null && toLong(makerId).equals(user.getId())) { + throw apiError(403, "Maker-checker: you cannot approve a " + what + " you raised yourself. Ask another manager."); + } + } + + private String requireReason(final String body) { + final String reason = fromJsonHelper.extractStringNamed("reason", fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}")); + if (reason == null || reason.isBlank()) { + throw apiError(400, "Reason is required"); + } + return reason.trim(); + } + + /** Checker authorization, the same rule the withdrawal and deposit request registers use. */ + private void validateCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject voucher corrections."); + } + } + + private static Long toLong(final Object value) { + return value instanceof Number n ? n.longValue() : null; + } + + private String log(final String existing, final String action, final AppUser user, final String note) { + final JsonArray entries = existing == null || existing.isBlank() ? new JsonArray() : gson.fromJson(existing, JsonArray.class); + final JsonObject e = new JsonObject(); + e.addProperty("action", action); + e.addProperty("by", user.getUsername()); + e.addProperty("at", Instant.now().toString()); + if (note != null) { + e.addProperty("note", note); + } + entries.add(e); + return gson.toJson(entries); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index 75aa18bf7..e02099946 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -628,9 +628,15 @@ public class VoucherVerificationApiResource { BigDecimal total = BigDecimal.ZERO; final List> out = new java.util.ArrayList<>(); + final Map> marks = voucherService + .voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList()); for (final Map r : rows) { final BigDecimal amount = (BigDecimal) r.get("amount"); - if (amount != null) { + final Map mark = marks.get((String) r.get("voucher_no")); + final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus")); + // A revoked voucher stays on the register, marked, but its money is on the voucher that replaced it, so it + // is left out of the total rather than counted twice. + if (amount != null && !revoked) { total = total.add(amount); } @@ -638,6 +644,7 @@ public class VoucherVerificationApiResource { m.put("voucherNo", r.get("voucher_no")); m.put("voucherType", r.get("voucher_type")); m.put("amount", amount); + putVoucherMark(m, mark); m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); // The day the posting belongs to, and whether that differs from the day it was written. // A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's @@ -847,6 +854,8 @@ public class VoucherVerificationApiResource { int raisedCount = 0; int verifiedCount = 0; final List> out = new java.util.ArrayList<>(); + final Map> myWorkMarks = voucherService + .voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList()); for (final Map r : rows) { final BigDecimal amount = (BigDecimal) r.get("amount"); @@ -869,7 +878,10 @@ public class VoucherVerificationApiResource { final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched ? "JV" : rawSide; - if (amount != null) { + final Map mark = myWorkMarks.get(voucherNo); + final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus")); + // A revoked voucher is shown, marked, but its money is on the voucher that replaced it, so it is not counted. + if (amount != null && !revoked) { if ("CR".equals(side)) { receivedTotal = receivedTotal.add(amount); } else if ("DR".equals(side)) { @@ -897,6 +909,7 @@ public class VoucherVerificationApiResource { m.put("raisedBy", r.get("created_by_username")); m.put("branchName", r.get("branch_name")); m.put("verificationStatus", r.get("verification_status")); + putVoucherMark(m, mark); m.put("verifiedByName", r.get("verified_by_name")); m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString()); m.put("debitParty", r.get("debit_party")); @@ -968,6 +981,14 @@ public class VoucherVerificationApiResource { * returns an empty list rather than an error: the voucher is real, its posting simply was not * captured at the time, and reporting it as missing would be wrong. */ + /** Adds where the voucher stands (active, revoked, under correction, corrected) to a register row. */ + private void putVoucherMark(final Map row, final Map mark) { + row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus")); + row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo")); + row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo")); + row.put("correctionNote", mark == null ? null : mark.get("correctionNote")); + } + @GET @Path("detail") public String detail(@QueryParam("voucherNo") final String voucherNo) { @@ -1032,6 +1053,7 @@ public class VoucherVerificationApiResource { out.put("createdBy", h.get("created_by_username")); out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString()); out.put("verificationStatus", h.get("verification_status")); + putVoucherMark(out, voucherService.voucherMarks(List.of(voucherNo)).get(voucherNo)); out.put("lines", lines); out.put("totalDebit", totalDebit); out.put("totalCredit", totalCredit); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index be8aa7f6b..a43c8b6a2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -595,6 +595,66 @@ public class VoucherService { """, voucherNo); } + /** + * Where each of these vouchers stands, for the registers that list vouchers. + * + * A voucher is ACTIVE until a correction touches it. REVOKED means its deposit was moved to another account and a + * new voucher replaced it (revokedByVoucherNo). A voucher with a correction under way reads UNPASS_PENDING, + * UNPASSED or CORRECTION_PENDING. The replacement itself reads CORRECTED, with the voucher it replaced in + * replacesVoucherNo. A register adds these to its rows and leaves a REVOKED voucher out of its totals, since its + * money now sits on the replacement. + */ + public Map> voucherMarks(final java.util.Collection voucherNos) { + final Map> marks = new java.util.HashMap<>(); + final List nos = voucherNos == null ? List.of() + : voucherNos.stream().filter(n -> n != null && !n.isBlank()).distinct().toList(); + if (nos.isEmpty()) { + return marks; + } + final String in = String.join(",", java.util.Collections.nCopies(nos.size(), "?")); + // Read from the correction register alone: the voucher itself carries no status, so nothing about a correction + // is ever written onto m_voucher and the voucher rows stay exactly as they were raised. + final List> rows = jdbcTemplate.queryForList( + "SELECT v.voucher_no," + + " (SELECT vc.new_voucher_no FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'" + + " ORDER BY vc.id DESC LIMIT 1) AS revoked_by_voucher_no," + + " (SELECT vc.original_voucher_no FROM m_voucher_correction vc" + + " WHERE vc.new_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'" + + " ORDER BY vc.id DESC LIMIT 1) AS replaces_voucher_no," + + " (SELECT vc.status FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no" + + " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED')" + + " ORDER BY vc.id DESC LIMIT 1) AS correction_status," + // The reason behind it: why a closed request was closed, otherwise why the voucher was unpassed. + + " (SELECT CASE WHEN vc.status = 'CLOSED' THEN vc.rejection_reason ELSE vc.reason END" + + " FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no" + + " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED', 'COMPLETED')" + + " ORDER BY vc.id DESC LIMIT 1) AS correction_note" + + " FROM m_voucher v WHERE v.voucher_no IN (" + in + ")", + nos.toArray()); + for (final Map r : rows) { + final String status; + if (r.get("revoked_by_voucher_no") != null) { + status = "REVOKED"; + } else if (r.get("correction_status") != null) { + status = (String) r.get("correction_status"); + } else if (r.get("replaces_voucher_no") != null) { + status = "CORRECTED"; + } else { + status = "ACTIVE"; + } + final Map m = new java.util.LinkedHashMap<>(); + m.put("voucherStatus", status); + m.put("revokedByVoucherNo", r.get("revoked_by_voucher_no")); + m.put("replacesVoucherNo", r.get("replaces_voucher_no")); + m.put("correctionNote", r.get("correction_note")); + marks.put((String) r.get("voucher_no"), m); + } + return marks; + } + /** * Looks up the voucher for the given request and returns its voucher_no, * or null if none exists (e.g. records created before vouchers existed). 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 53762ea3e..57c967250 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 @@ -175,4 +175,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml new file mode 100644 index 000000000..8fe23388a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml new file mode 100644 index 000000000..d1aaa1cb2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + From 6be32272c1c9da48f67aeb6b0a4566a444863875 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:27:22 +0530 Subject: [PATCH 6/6] feat(cashier): maker-checker on cash transfer Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and posted only when a different manager or head cashier approves it. - POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same checks run again at approval, since the vault can change in between. - Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a reason; the maker cannot approve their own request. - An identical request from the same maker is refused while the first is still pending. - The direct POST /cashtransfer is closed so the approval cannot be bypassed. - Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../api/CashTransferApiResource.java | 196 +++++++++++++++++- .../tenant/final-changelog-tenant.xml | 1 + .../parts/0603_cash_transfer_request.xml | 46 ++++ 3 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml 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 437999967..3c87ac908 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 @@ -57,9 +57,23 @@ public class CashTransferApiResource { private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); + /** + * Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see + * {@link #submitRequest}) and only a different person's approval posts it. The posting itself is + * {@link #postTransfer}. + */ @POST public String create(String body) { - this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER"); + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required", + "A cash transfer is raised as a request and posted when a manager approves it."); + } + + /** + * Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry. + * A dry run stops after every check has passed, which is what a request is held to when it is raised and again + * when it is approved, since the vault can change in between. + */ + private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) { JsonObject json = JsonParser.parseString(body).getAsJsonObject(); final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull() @@ -102,6 +116,9 @@ public class CashTransferApiResource { } validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate); + if (dryRun) { + return "{}"; + } CashTransfer header = new CashTransfer(); header.setTransferDate(transferDate); @@ -112,15 +129,9 @@ public class CashTransferApiResource { header.setTransferAmount(total); header.setNote(note); header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - try { - AppUser user = this.context.authenticatedUser(); - if (user != null) { - header.setPerformedBy(user.getId()); - header.setPerformedByUsername(user.getUsername()); - } - } catch (Exception e) { - // resolving the current user must never block recording the transfer - } + // Recorded against the person who raised the request, not the one who approved it. + header.setPerformedBy(performedBy); + header.setPerformedByUsername(performedByUsername); // Save header + details in one committed transaction, then GL dispatch separately. // This prevents EclipseLink from flushing pending detail rows inside Fineract's @@ -159,6 +170,171 @@ public class CashTransferApiResource { return gson.toJson(result); } + // ── Maker-checker: the allocation is a request until a different person approves it ──────────── + + /** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */ + @POST + @Path("requests") + public String submitRequest(final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("CREATE_CASHTRANSFER"); + postTransfer(body, user.getId(), user.getUsername(), true); + + // The same request, from the same person, is not raised twice while the first is still waiting: a second tap or + // a second click would otherwise release the same cash twice once both were approved. + final Integer identical = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class, + REQUEST_PENDING, user.getId(), body); + if (identical != null && identical > 0) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate", + "An identical cash transfer request from you is already waiting for approval."); + } + + final JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + final JsonArray denominations = json.getAsJsonArray("denominations"); + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_cash_transfer_request + (transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body, + status, maker_id, maker_name, maker_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + RETURNING id + """, Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")), + json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"), + textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(), + user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now())); + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_PENDING); + return gson.toJson(result); + } + + /** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */ + @GET + @Path("requests") + public String listRequests(@QueryParam("status") final String status, + @QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) { + final AppUser user = this.context.authenticatedUser(); + final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1"); + final List params = new java.util.ArrayList<>(); + if (status != null && !status.isBlank()) { + final String[] statuses = status.split(","); + sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")"); + for (final String s : statuses) { + params.add(s.trim().toUpperCase()); + } + } + if (mine) { + sql.append(" AND maker_id = ?"); + params.add(user.getId()); + } + sql.append(" ORDER BY id DESC LIMIT 200"); + final List> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray()); + for (final Map row : rows) { + // The notes asked for, so the approver sees what they are releasing without opening anything. + final Object requestBody = row.remove("request_body"); + row.put("denominations", requestBody == null ? new JsonArray() + : JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations")); + } + return gson.toJson(rows); + } + + /** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */ + @POST + @Path("requests/{id}/approve") + public String approveRequest(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + requireChecker(user); + final Map r = loadRequest(id); + requirePending(r, "approved"); + final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null; + if (makerId != null && makerId.equals(user.getId())) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user", + "Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager."); + } + final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false); + final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject(); + final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null; + + final int updated = jdbcTemplate.update(""" + UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ? + WHERE id = ? AND status = ? + """, REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()), + transferId, id, REQUEST_PENDING); + if (updated == 0) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned", + "This request was already actioned by another manager."); + } + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_APPROVED); + if (transferId != null) { + result.addProperty("cashTransferId", transferId); + } + if (postedJson.has("glWarning")) { + result.add("glWarning", postedJson.get("glWarning")); + } + return gson.toJson(result); + } + + @POST + @Path("requests/{id}/reject") + public String rejectRequest(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + requireChecker(user); + final Map r = loadRequest(id); + requirePending(r, "rejected"); + final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject(); + final String reason = textOrNull(json, "reason"); + if (reason == null || reason.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject."); + } + jdbcTemplate.update(""" + UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ? + WHERE id = ? AND status = ? + """, REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()), + reason.trim(), id, REQUEST_PENDING); + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_REJECTED); + return gson.toJson(result); + } + + private static final String REQUEST_PENDING = "PENDING"; + private static final String REQUEST_APPROVED = "APPROVED"; + private static final String REQUEST_REJECTED = "REJECTED"; + + private Map loadRequest(final Long id) { + final List> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found."); + } + return rows.get(0); + } + + private void requirePending(final Map request, final String action) { + if (!REQUEST_PENDING.equals(request.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status", + "Only PENDING requests can be " + action + ". Current status: " + request.get("status")); + } + } + + /** Checker authorization, the same rule the withdrawal and deposit request registers use. */ + private void requireChecker(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker", + "Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer."); + } + } + + private static String textOrNull(final JsonObject json, final String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null; + } + @GET public String getAll( @QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset, 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 57c967250..959cdfcfa 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 @@ -177,4 +177,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml new file mode 100644 index 000000000..5d88f52d8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +