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/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); /** 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..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 @@ -14,11 +14,13 @@ 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; 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; @@ -35,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(); /** @@ -328,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; } @@ -349,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) { @@ -1083,6 +1097,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..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 @@ -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; @@ -58,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() @@ -103,6 +116,9 @@ public class CashTransferApiResource { } validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate); + if (dryRun) { + return "{}"; + } CashTransfer header = new CashTransfer(); header.setTransferDate(transferDate); @@ -113,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 @@ -160,65 +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("{id}/reverse") - @Transactional - public String reverse(@PathParam("id") Long id, String body) { - this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER"); + @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); - 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."); + // 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."); } - 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(); + 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("reversed", true); + 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/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/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/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 = "{}"; 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 7ef1ca7f7..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 @@ -173,4 +173,9 @@ + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +