From b47a34b5ef103bd9b3ee256535c3e93928eff1b2 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:27:21 +0530 Subject: [PATCH] feat(vouchers): voucher correction for cash deposits posted to the wrong account A cash deposit posted to the wrong savings account is corrected without ever undoing it behind anyone's back: the voucher is unpassed (admin or clerk), a manager approves, the cashier enters the correct account, and a manager approves again. Only the final approval moves money: the deposit is reversed on the wrong account, the same cash is deposited into the right one under a new voucher, and the cashier's denominations follow it. - VoucherCorrectionApiResource: eligible list, unpass, approve/reject unpass, account lookup, correction, approve/reject correction, close request, voided transactions. - The wrong account's balance is checked when the cashier enters the correction and again at approval. - A cashier whose customer has already used the money closes the request and deposits separately. - Roles are enforced on the server: unpass for admin/clerk, correction and close for a cashier, approvals for a manager or head cashier other than the maker. - Migration 0601 adds m_voucher_correction; 0602 drops the status columns an earlier draft put on m_voucher, since the correction status is read from m_voucher_correction alone. - Voucher registers (register, My Vouchers, day book, voucher detail) report each voucher's correction status and leave a revoked voucher out of their totals, so its cash is not counted twice. Co-Authored-By: Claude Sonnet 5.5 --- .../cashreport/api/CashReportApiResource.java | 15 +- .../api/VoucherCorrectionApiResource.java | 623 ++++++++++++++++++ .../api/VoucherVerificationApiResource.java | 26 +- .../voucher/service/VoucherService.java | 60 ++ .../tenant/final-changelog-tenant.xml | 2 + .../tenant/parts/0601_voucher_correction.xml | 75 +++ .../0602_voucher_drop_status_columns.xml | 24 + 7 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index afb0ed8bc..ca448a2c8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -20,6 +20,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; +import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -36,6 +37,7 @@ public class CashReportApiResource { private final DenominationBreakdownWriter denominationBreakdownWriter; private final CashierRoleHelper cashierRoleHelper; private final VaultPositionService vaultPositionService; + private final VoucherService voucherService; private final Gson gson = new Gson(); /** @@ -329,7 +331,18 @@ public class CashReportApiResource { BigDecimal priorCreditTotal = BigDecimal.ZERO; BigDecimal priorDebitTotal = BigDecimal.ZERO; + // Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on + // the voucher that replaced it, so it must not be counted in the day's totals a second time. + final Map> marks = voucherService.voucherMarks( + rows.stream().map(r -> (String) r.get("voucherNo")).toList()); + for (final Map row : rows) { + final Map mark = marks.get((String) row.get("voucherNo")); + row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus")); + row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo")); + row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo")); + row.put("correctionNote", mark == null ? null : mark.get("correctionNote")); + final boolean revoked = "REVOKED".equals(row.get("voucherStatus")); if (sideFilter != null && !sideFilter.equals(row.get("side"))) { continue; } @@ -350,7 +363,7 @@ public class CashReportApiResource { // was already earned, and the day it belongs to has been counted and signed off. Adding // it to this day's totals would make them disagree with the ledger for either date. final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section")); - if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) + if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) { final BigDecimal amount = (BigDecimal) row.get("amount"); if (amount != null) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java new file mode 100644 index 000000000..d153d8588 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherCorrectionApiResource.java @@ -0,0 +1,623 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.voucher.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper; +import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Corrects a cash deposit that went to the wrong savings account, without ever "undoing" it behind the + * cashier's back. + * + *
    + *
  1. The cashier picks the day's voucher and asks to unpass it (UNPASS_PENDING). Nothing moves.
  2. + *
  3. A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply + * waiting for its correction entry.
  4. + *
  5. The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number + * (CORRECTION_PENDING).
  6. + *
  7. A manager approves the correction (COMPLETED). Only now do accounts change: the original deposit is + * reversed on the wrong account and the same cash is deposited into the right one, under a new voucher; the + * original voucher is revoked and the cashier's denominations follow the deposit.
  8. + *
+ * + * The balance of the wrong account is checked twice, when the cashier enters the correction and again when it is + * approved, because the customer may have drawn the money in between. + */ +@Path("/v1/vouchers/corrections") +@Component +@Tag(name = "Voucher Corrections", description = "Unpass a wrongly-posted cash deposit voucher and re-post it to the right account.") +@RequiredArgsConstructor +public class VoucherCorrectionApiResource { + + public static final String REF_VOUCHER_CORRECTION = "VOUCHER_CORRECTION"; + + private static final String UNPASS_PENDING = "UNPASS_PENDING"; + private static final String UNPASSED = "UNPASSED"; + private static final String UNPASS_REJECTED = "UNPASS_REJECTED"; + private static final String CORRECTION_PENDING = "CORRECTION_PENDING"; + private static final String COMPLETED = "COMPLETED"; + private static final String CLOSED = "CLOSED"; + private static final int SAVINGS_STATUS_ACTIVE = 300; + private static final int DEPOSIT_TYPE_SAVINGS = 100; + + private static final DateTimeFormatter VOUCHER_DAY = DateTimeFormatter.ofPattern("ddMMyyyy"); + + private final JdbcTemplate jdbcTemplate; + private final PlatformSecurityContext context; + private final FromJsonHelper fromJsonHelper; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final VoucherService voucherService; + private final KycRestrictionGuard kycRestrictionGuard; + private final CashierRoleHelper cashierRoleHelper; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ── Today's vouchers the cashier may unpass ────────────────────────────────── + + /** Today's cash deposit vouchers on a savings account that have not been reversed, revoked or taken up already. */ + @GET + @Path("eligible") + @Produces(MediaType.APPLICATION_JSON) + public String eligible(@QueryParam("search") final String search) { + requireUnpasser(this.context.authenticatedUser()); + final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY); + final List params = new ArrayList<>(); + params.add("%/" + day + "/%"); + final StringBuilder sql = new StringBuilder(ELIGIBLE_SQL); + if (search != null && !search.isBlank()) { + sql.append(" AND (v.voucher_no ILIKE ? OR sa.account_no ILIKE ? OR c.display_name ILIKE ?)"); + final String like = "%" + search.trim() + "%"; + params.add(like); + params.add(like); + params.add(like); + } + sql.append(" ORDER BY v.id DESC"); + return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray())); + } + + private static final String ELIGIBLE_SQL = """ + SELECT v.voucher_no AS "voucherNo", v.amount, v.created_at AS "createdAt", + v.created_by_username AS "cashier", v.savings_txn_id AS "txnId", + t.savings_account_id AS "savingsAccountId", sa.account_no AS "accountNo", + c.display_name AS "clientName", pt.value AS "paymentType" + FROM m_voucher v + JOIN m_savings_account_transaction t ON t.id = v.savings_txn_id + JOIN m_savings_account sa ON sa.id = t.savings_account_id + LEFT JOIN m_client c ON c.id = sa.client_id + JOIN m_payment_detail pd ON pd.id = t.payment_detail_id + JOIN m_payment_type pt ON pt.id = pd.payment_type_id + WHERE v.voucher_type = 'DEPOSIT' + AND COALESCE(v.reference_type, '') <> 'VOUCHER_CORRECTION' + AND v.voucher_no LIKE ? + AND t.is_reversed = false + AND t.transaction_type_enum = 1 + AND pt.is_cash_payment = true + AND sa.deposit_type_enum = 100 + AND NOT EXISTS (SELECT 1 FROM m_voucher_correction vc + WHERE vc.original_voucher_no = v.voucher_no + AND vc.status NOT IN ('UNPASS_REJECTED')) + """; + + // ── Step 1: cashier unpasses a voucher ─────────────────────────────────────── + + @POST + @Path("unpass") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String unpass(final String body) { + final AppUser user = this.context.authenticatedUser(); + requireUnpasser(user); + final JsonElement root = fromJsonHelper.parse(body); + final String voucherNo = fromJsonHelper.extractStringNamed("voucherNo", root); + final String reason = fromJsonHelper.extractStringNamed("reason", root); + if (voucherNo == null || voucherNo.isBlank()) { + throw apiError(400, "Voucher number is required"); + } + if (reason == null || reason.isBlank()) { + throw apiError(400, "Reason is required"); + } + + final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY); + final List> rows = jdbcTemplate.queryForList(ELIGIBLE_SQL + " AND v.voucher_no = ?", "%/" + day + "/%", + voucherNo.trim()); + if (rows.isEmpty()) { + throw apiError(409, "Voucher " + voucherNo + + " cannot be unpassed: only today's cash deposit vouchers on a savings account that are still active qualify."); + } + final Map v = rows.get(0); + + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_voucher_correction + (original_voucher_no, original_txn_id, wrong_savings_account_id, wrong_account_no, wrong_client_name, + amount, payment_type_id, txn_date, status, reason, unpass_by_id, unpass_by_name, unpass_at, activity_log) + VALUES (?,?,?,?,?,?,(SELECT pd.payment_type_id FROM m_savings_account_transaction t + JOIN m_payment_detail pd ON pd.id = t.payment_detail_id WHERE t.id = ?), + (SELECT transaction_date FROM m_savings_account_transaction WHERE id = ?), + ?,?,?,?,?,?) + RETURNING id + """, Long.class, voucherNo.trim(), v.get("txnId"), v.get("savingsAccountId"), v.get("accountNo"), v.get("clientName"), + v.get("amount"), v.get("txnId"), v.get("txnId"), UNPASS_PENDING, reason.trim(), user.getId(), user.getDisplayName(), + Timestamp.from(Instant.now()), log(null, "UNPASS REQUESTED", user, reason.trim())); + return gson.toJson(Map.of("resourceId", id, "status", UNPASS_PENDING)); + } + + // ── Lists ──────────────────────────────────────────────────────────────────── + + /** + * Corrections by status. {@code status} may be a comma-separated list. The approval page asks for + * UNPASS_PENDING and CORRECTION_PENDING, the cashier's page for UNPASSED. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public String list(@QueryParam("status") final String status, @QueryParam("mine") @DefaultValue("false") final boolean mine) { + final AppUser user = this.context.authenticatedUser(); + final StringBuilder sql = new StringBuilder("SELECT * FROM m_voucher_correction WHERE 1=1"); + final List params = new ArrayList<>(); + if (status != null && !status.isBlank()) { + final String[] statuses = status.split(","); + sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")"); + for (final String s : statuses) { + params.add(s.trim().toUpperCase()); + } + } + if (mine) { + sql.append(" AND (unpass_by_id = ? OR correction_by_id = ?)"); + params.add(user.getId()); + params.add(user.getId()); + } + sql.append(" ORDER BY id DESC LIMIT 200"); + return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray())); + } + + @GET + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public String get(@PathParam("id") final Long id) { + this.context.authenticatedUser(); + return gson.toJson(load(id)); + } + + // ── Step 2: manager approves / rejects the unpass ──────────────────────────── + + @POST + @Path("{id}/unpass/approve") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String approveUnpass(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, UNPASS_PENDING, "approved"); + requireNotMaker(user, r.get("unpass_by_id"), "unpass request"); + final int updated = jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, unpass_approved_by_id = ?, unpass_approved_by_name = ?, + unpass_approved_at = ?, activity_log = ? + WHERE id = ? AND status = ? + """, UNPASSED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), + log((String) r.get("activity_log"), "UNPASS APPROVED", user, null), id, UNPASS_PENDING); + if (updated == 0) { + throw apiError(409, "This request was already actioned by another manager."); + } + // Deliberately no account movement here: the deposit stays exactly where it is until the correction is approved. + return gson.toJson(Map.of("resourceId", id, "status", UNPASSED)); + } + + @POST + @Path("{id}/unpass/reject") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String rejectUnpass(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, UNPASS_PENDING, "rejected"); + final String reason = requireReason(body); + jdbcTemplate.update("UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ?", UNPASS_REJECTED, + reason, log((String) r.get("activity_log"), "UNPASS REJECTED", user, reason), id); + return gson.toJson(Map.of("resourceId", id, "status", UNPASS_REJECTED)); + } + + // ── Step 3: cashier enters the correct account ─────────────────────────────── + + /** Resolves the account number the cashier typed: name, balance and whether it can receive the deposit. */ + @GET + @Path("account-lookup") + @Produces(MediaType.APPLICATION_JSON) + public String accountLookup(@QueryParam("accountNo") final String accountNo) { + this.context.authenticatedUser(); + final Map account = findSavingsAccount(accountNo); + if (account == null) { + throw apiError(404, "No savings account found for '" + accountNo + "'."); + } + return gson.toJson(account); + } + + @POST + @Path("{id}/correction") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String submitCorrection(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + // The corrected entry is the cashier's own: a deposit is taken at the counter, so only a cashier can re-enter it. + if (cashierRoleHelper.getCurrentCashierId() == null) { + throw apiError(403, "Only a cashier can enter the correction for a voucher. Your login is not linked to a cashier."); + } + final Map r = load(id); + requireStatus(r, UNPASSED, "corrected"); + final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body)); + final Map correct = validateCorrectAccount(r, accountNo); + checkWrongAccountBalance(r); + + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, correct_savings_account_id = ?, correct_account_no = ?, + correct_client_id = ?, correct_client_name = ?, correction_by_id = ?, correction_by_name = ?, + correction_at = ?, rejection_reason = NULL, activity_log = ? + WHERE id = ? AND status = ? + """, CORRECTION_PENDING, correct.get("id"), correct.get("accountNo"), correct.get("clientId"), correct.get("clientName"), + user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), + log((String) r.get("activity_log"), "CORRECTION SUBMITTED", user, "to " + correct.get("accountNo")), id, UNPASSED); + return gson.toJson(Map.of("resourceId", id, "status", CORRECTION_PENDING)); + } + + // ── Step 4: manager approves / rejects the correction ──────────────────────── + + @POST + @Path("{id}/correction/approve") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String approveCorrection(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, CORRECTION_PENDING, "approved"); + requireNotMaker(user, r.get("correction_by_id"), "correction"); + + // Both sides are checked again: a lot can happen between the cashier saving and the manager approving. + validateCorrectAccount(r, (String) r.get("correct_account_no")); + checkWrongAccountBalance(r); + + final Long wrongId = toLong(r.get("wrong_savings_account_id")); + final Long correctId = toLong(r.get("correct_savings_account_id")); + final Long origTxnId = toLong(r.get("original_txn_id")); + final BigDecimal amount = new BigDecimal(r.get("amount").toString()); + final String origVoucher = (String) r.get("original_voucher_no"); + + final Integer stillLive = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_savings_account_transaction WHERE id = ? AND savings_account_id = ? AND is_reversed = false", + Integer.class, origTxnId, wrongId); + if (stillLive == null || stillLive == 0) { + throw apiError(409, "The original deposit has already been reversed; this voucher can no longer be corrected."); + } + + // 1. Take the deposit off the wrong account, 2. put the same cash into the right one. + final JsonObject undoPayload = new JsonObject(); + voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource( + new CommandWrapperBuilder().undoSavingsAccountTransaction(wrongId, origTxnId).withJson(undoPayload.toString()).build())); + + final String newTxnId = depositInto(correctId, amount, r); + final Long newTxnKey = Long.valueOf(newTxnId); + + // 3. A new voucher for the corrected entry, in the name of the cashier who made it, with the same till side. + final String newVoucher = voucherService.createVoucher(VoucherService.TYPE_DEPOSIT, REF_VOUCHER_CORRECTION, id, amount, user); + jdbcTemplate.update("UPDATE m_voucher SET created_by = ?, created_by_username = (SELECT username FROM m_appuser WHERE id = ?) WHERE voucher_no = ?", + r.get("correction_by_id"), r.get("correction_by_id"), newVoucher); + voucherService.linkSavingsTransaction(REF_VOUCHER_CORRECTION, id, newTxnKey); + addNewVoucherLines(newVoucher, origVoucher, correctId, (String) r.get("correct_client_name"), (String) r.get("correct_account_no"), + amount); + + // 4. The original voucher is not touched. That it is revoked, and by which voucher, is recorded on this + // correction (original_voucher_no / new_voucher_no) and read from there by the registers. + + // 5. The notes the cashier took in follow the deposit to its new transaction and account. + jdbcTemplate.update(""" + UPDATE m_savings_txn_denomination SET txn_id = ?, savings_account_id = ?, account_no = ?, client_id = ? + WHERE txn_id = ? AND savings_account_id = ? + """, newTxnKey, correctId, r.get("correct_account_no"), r.get("correct_client_id"), origTxnId, wrongId); + + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, correction_approved_by_id = ?, correction_approved_by_name = ?, + correction_approved_at = ?, new_voucher_no = ?, new_txn_id = ?, activity_log = ? + WHERE id = ? AND status = ? + """, COMPLETED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), newVoucher, newTxnKey, + log((String) r.get("activity_log"), "CORRECTION APPROVED", user, "new voucher " + newVoucher), id, CORRECTION_PENDING); + + return gson.toJson(Map.of("resourceId", id, "status", COMPLETED, "newVoucherNo", newVoucher)); + } + + /** A rejected correction goes back to the cashier as UNPASSED, to enter the account again. */ + @POST + @Path("{id}/correction/reject") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String rejectCorrection(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + validateCheckerRole(user); + final Map r = load(id); + requireStatus(r, CORRECTION_PENDING, "rejected"); + final String reason = requireReason(body); + jdbcTemplate.update(""" + UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, correct_savings_account_id = NULL, + correct_account_no = NULL, correct_client_id = NULL, correct_client_name = NULL, activity_log = ? + WHERE id = ? + """, UNPASSED, reason, log((String) r.get("activity_log"), "CORRECTION REJECTED", user, reason), id); + return gson.toJson(Map.of("resourceId", id, "status", UNPASSED)); + } + + /** + * The cashier gives up the correction. Used when the wrong account no longer holds the money (the customer has + * already drawn it), so it cannot be taken back: the cashier then puts the amount into the right account from + * their own pocket as a fresh deposit, and this request is closed. No account moves here, and the original voucher + * stays as it was. + */ + @POST + @Path("{id}/close") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String close(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + if (cashierRoleHelper.getCurrentCashierId() == null) { + throw apiError(403, "Only a cashier can close a voucher correction."); + } + final Map r = load(id); + requireStatus(r, UNPASSED, "closed"); + final String reason = requireReason(body); + final int updated = jdbcTemplate.update( + "UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ? AND status = ?", CLOSED, + reason, log((String) r.get("activity_log"), "CLOSED BY CASHIER", user, reason), id, UNPASSED); + if (updated == 0) { + throw apiError(409, "This request was already actioned."); + } + return gson.toJson(Map.of("resourceId", id, "status", CLOSED)); + } + + // ── For the passbook and transaction list of the wrong account ─────────────── + + /** Transactions of one account that were removed by a completed correction, so its screens can leave them out. */ + @GET + @Path("voided-transactions") + @Produces(MediaType.APPLICATION_JSON) + public String voidedTransactions(@QueryParam("savingsAccountId") final Long savingsAccountId) { + this.context.authenticatedUser(); + final JsonArray ids = new JsonArray(); + jdbcTemplate.queryForList( + "SELECT original_txn_id FROM m_voucher_correction WHERE status = ? AND wrong_savings_account_id = ?", Long.class, COMPLETED, + savingsAccountId).forEach(ids::add); + return gson.toJson(ids); + } + + // ── helpers ────────────────────────────────────────────────────────────────── + + /** Unpassing a voucher is for the back office: an administrator or a clerk. The cashier does not unpass, only re-enters. */ + private void requireUnpasser(final AppUser user) { + final boolean admin = user.hasAnyPermission("ALL_FUNCTIONS"); + final boolean clerk = user.getRoles().stream().anyMatch(role -> role.getName() != null && "clerk".equals(role.getName().toLowerCase().trim())); + if (!admin && !clerk) { + throw apiError(403, "Only an administrator or a clerk can unpass a voucher."); + } + } + + private Map validateCorrectAccount(final Map r, final String accountNo) { + if (accountNo == null || accountNo.isBlank()) { + throw apiError(400, "The correct account number is required"); + } + final Map correct = findSavingsAccount(accountNo); + if (correct == null) { + throw apiError(404, "No savings account found for '" + accountNo + "'."); + } + if (toLong(correct.get("id")).equals(toLong(r.get("wrong_savings_account_id")))) { + throw apiError(400, "The correct account is the same account the deposit was posted to."); + } + if (!Boolean.TRUE.equals(correct.get("canReceive"))) { + throw apiError(409, "Account " + correct.get("accountNo") + " is not active, so it cannot receive this deposit."); + } + final Object wrongCurrency = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class, + r.get("wrong_savings_account_id")).stream().findFirst().orElse(null); + if (wrongCurrency != null && !wrongCurrency.equals(correct.get("currencyCode"))) { + throw apiError(409, "The correct account is in a different currency."); + } + kycRestrictionGuard.check(toLong(correct.get("id")), KycRestrictionGuard.Operation.CREDIT); + return correct; + } + + /** The wrong account must still hold at least this deposit, or taking it back would overdraw it. */ + private void checkWrongAccountBalance(final Map r) { + final BigDecimal balance = jdbcTemplate.queryForObject("SELECT account_balance_derived FROM m_savings_account WHERE id = ?", + BigDecimal.class, r.get("wrong_savings_account_id")); + final BigDecimal amount = new BigDecimal(r.get("amount").toString()); + if (balance == null || balance.compareTo(amount) < 0) { + throw apiError(409, "Account " + r.get("wrong_account_no") + " no longer holds ₹" + amount + " (balance ₹" + + (balance == null ? "0" : balance) + "), so the deposit cannot be taken back. The customer may have used the money."); + } + } + + private Map findSavingsAccount(final String accountNo) { + if (accountNo == null || accountNo.isBlank()) { + return null; + } + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no AS "accountNo", sa.client_id AS "clientId", c.display_name AS "clientName", + sa.account_balance_derived AS "balance", sa.currency_code AS "currencyCode", + (sa.status_enum = ? AND sa.deposit_type_enum = ?) AS "canReceive" + FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id + WHERE sa.account_no = ? + """, SAVINGS_STATUS_ACTIVE, DEPOSIT_TYPE_SAVINGS, accountNo.trim()); + return rows.isEmpty() ? null : rows.get(0); + } + + private String depositInto(final Long savingsAccountId, final BigDecimal amount, final Map r) { + final Object txnDate = r.get("txn_date"); + final String date = txnDate == null ? DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) + : LocalDate.parse(txnDate.toString().substring(0, 10)).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("transactionDate", date); + payload.addProperty("transactionAmount", amount); + if (r.get("payment_type_id") != null) { + payload.addProperty("paymentTypeId", ((Number) r.get("payment_type_id")).longValue()); + } + payload.addProperty("note", "Correction of voucher " + r.get("original_voucher_no")); + final CommandWrapper command = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString()) + .build(); + final CommandProcessingResult result = voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(command)); + if (result.getResourceId() == null) { + throw apiError(500, "The corrected deposit could not be posted."); + } + return result.getResourceId().toString(); + } + + /** The new voucher keeps the original's till side and names the right account on the credit side. */ + private void addNewVoucherLines(final String newVoucher, final String origVoucher, final Long correctId, final String clientName, + final String accountNo, final BigDecimal amount) { + try { + final List lines = new ArrayList<>(); + for (final Map l : jdbcTemplate.queryForList( + "SELECT gl_account_id, party_name, account_no FROM m_voucher_line WHERE voucher_no = ? AND entry_type = 'DR' ORDER BY line_no", + origVoucher)) { + lines.add(VoucherLine.debitGl(toLong(l.get("gl_account_id")), (String) l.get("party_name"), (String) l.get("account_no"), + amount)); + } + if (lines.isEmpty()) { + lines.add(VoucherLine.debitGl(null, "Cash in Hand", null, amount)); + } + lines.add(VoucherLine.creditSavings(correctId, clientName, accountNo, amount)); + voucherService.addLines(newVoucher, lines); + } catch (final RuntimeException e) { + // The deposit is already posted; a voucher without its sides is better than undoing a correct posting. + org.slf4j.LoggerFactory.getLogger(VoucherCorrectionApiResource.class) + .error("Could not record the sides of correction voucher {}", newVoucher, e); + } + } + + private Map load(final Long id) { + final List> rows = jdbcTemplate.queryForList("SELECT * FROM m_voucher_correction WHERE id = ?", id); + if (rows.isEmpty()) { + throw new NotFoundException("Voucher correction not found: " + id); + } + return rows.get(0); + } + + private void requireStatus(final Map r, final String expected, final String action) { + if (!expected.equals(r.get("status"))) { + throw apiError(409, "Only " + expected + " requests can be " + action + ". Current status: " + r.get("status")); + } + } + + private void requireNotMaker(final AppUser user, final Object makerId, final String what) { + if (makerId != null && toLong(makerId).equals(user.getId())) { + throw apiError(403, "Maker-checker: you cannot approve a " + what + " you raised yourself. Ask another manager."); + } + } + + private String requireReason(final String body) { + final String reason = fromJsonHelper.extractStringNamed("reason", fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}")); + if (reason == null || reason.isBlank()) { + throw apiError(400, "Reason is required"); + } + return reason.trim(); + } + + /** Checker authorization, the same rule the withdrawal and deposit request registers use. */ + private void validateCheckerRole(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject voucher corrections."); + } + } + + private static Long toLong(final Object value) { + return value instanceof Number n ? n.longValue() : null; + } + + private String log(final String existing, final String action, final AppUser user, final String note) { + final JsonArray entries = existing == null || existing.isBlank() ? new JsonArray() : gson.fromJson(existing, JsonArray.class); + final JsonObject e = new JsonObject(); + e.addProperty("action", action); + e.addProperty("by", user.getUsername()); + e.addProperty("at", Instant.now().toString()); + if (note != null) { + e.addProperty("note", note); + } + entries.add(e); + return gson.toJson(entries); + } + + private WebApplicationException apiError(final int httpStatus, final String message) { + final JsonObject body = new JsonObject(); + body.addProperty("message", message); + body.addProperty("defaultUserMessage", message); + body.addProperty("httpStatus", httpStatus); + final JsonArray errors = new JsonArray(); + final JsonObject err = new JsonObject(); + err.addProperty("defaultUserMessage", message); + errors.add(err); + body.add("errors", errors); + return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java index 75aa18bf7..e02099946 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/api/VoucherVerificationApiResource.java @@ -628,9 +628,15 @@ public class VoucherVerificationApiResource { BigDecimal total = BigDecimal.ZERO; final List> out = new java.util.ArrayList<>(); + final Map> marks = voucherService + .voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList()); for (final Map r : rows) { final BigDecimal amount = (BigDecimal) r.get("amount"); - if (amount != null) { + final Map mark = marks.get((String) r.get("voucher_no")); + final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus")); + // A revoked voucher stays on the register, marked, but its money is on the voucher that replaced it, so it + // is left out of the total rather than counted twice. + if (amount != null && !revoked) { total = total.add(amount); } @@ -638,6 +644,7 @@ public class VoucherVerificationApiResource { m.put("voucherNo", r.get("voucher_no")); m.put("voucherType", r.get("voucher_type")); m.put("amount", amount); + putVoucherMark(m, mark); m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString()); // The day the posting belongs to, and whether that differs from the day it was written. // A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's @@ -847,6 +854,8 @@ public class VoucherVerificationApiResource { int raisedCount = 0; int verifiedCount = 0; final List> out = new java.util.ArrayList<>(); + final Map> myWorkMarks = voucherService + .voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList()); for (final Map r : rows) { final BigDecimal amount = (BigDecimal) r.get("amount"); @@ -869,7 +878,10 @@ public class VoucherVerificationApiResource { final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched ? "JV" : rawSide; - if (amount != null) { + final Map mark = myWorkMarks.get(voucherNo); + final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus")); + // A revoked voucher is shown, marked, but its money is on the voucher that replaced it, so it is not counted. + if (amount != null && !revoked) { if ("CR".equals(side)) { receivedTotal = receivedTotal.add(amount); } else if ("DR".equals(side)) { @@ -897,6 +909,7 @@ public class VoucherVerificationApiResource { m.put("raisedBy", r.get("created_by_username")); m.put("branchName", r.get("branch_name")); m.put("verificationStatus", r.get("verification_status")); + putVoucherMark(m, mark); m.put("verifiedByName", r.get("verified_by_name")); m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString()); m.put("debitParty", r.get("debit_party")); @@ -968,6 +981,14 @@ public class VoucherVerificationApiResource { * returns an empty list rather than an error: the voucher is real, its posting simply was not * captured at the time, and reporting it as missing would be wrong. */ + /** Adds where the voucher stands (active, revoked, under correction, corrected) to a register row. */ + private void putVoucherMark(final Map row, final Map mark) { + row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus")); + row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo")); + row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo")); + row.put("correctionNote", mark == null ? null : mark.get("correctionNote")); + } + @GET @Path("detail") public String detail(@QueryParam("voucherNo") final String voucherNo) { @@ -1032,6 +1053,7 @@ public class VoucherVerificationApiResource { out.put("createdBy", h.get("created_by_username")); out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString()); out.put("verificationStatus", h.get("verification_status")); + putVoucherMark(out, voucherService.voucherMarks(List.of(voucherNo)).get(voucherNo)); out.put("lines", lines); out.put("totalDebit", totalDebit); out.put("totalCredit", totalCredit); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java index be8aa7f6b..a43c8b6a2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -595,6 +595,66 @@ public class VoucherService { """, voucherNo); } + /** + * Where each of these vouchers stands, for the registers that list vouchers. + * + * A voucher is ACTIVE until a correction touches it. REVOKED means its deposit was moved to another account and a + * new voucher replaced it (revokedByVoucherNo). A voucher with a correction under way reads UNPASS_PENDING, + * UNPASSED or CORRECTION_PENDING. The replacement itself reads CORRECTED, with the voucher it replaced in + * replacesVoucherNo. A register adds these to its rows and leaves a REVOKED voucher out of its totals, since its + * money now sits on the replacement. + */ + public Map> voucherMarks(final java.util.Collection voucherNos) { + final Map> marks = new java.util.HashMap<>(); + final List nos = voucherNos == null ? List.of() + : voucherNos.stream().filter(n -> n != null && !n.isBlank()).distinct().toList(); + if (nos.isEmpty()) { + return marks; + } + final String in = String.join(",", java.util.Collections.nCopies(nos.size(), "?")); + // Read from the correction register alone: the voucher itself carries no status, so nothing about a correction + // is ever written onto m_voucher and the voucher rows stay exactly as they were raised. + final List> rows = jdbcTemplate.queryForList( + "SELECT v.voucher_no," + + " (SELECT vc.new_voucher_no FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'" + + " ORDER BY vc.id DESC LIMIT 1) AS revoked_by_voucher_no," + + " (SELECT vc.original_voucher_no FROM m_voucher_correction vc" + + " WHERE vc.new_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'" + + " ORDER BY vc.id DESC LIMIT 1) AS replaces_voucher_no," + + " (SELECT vc.status FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no" + + " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED')" + + " ORDER BY vc.id DESC LIMIT 1) AS correction_status," + // The reason behind it: why a closed request was closed, otherwise why the voucher was unpassed. + + " (SELECT CASE WHEN vc.status = 'CLOSED' THEN vc.rejection_reason ELSE vc.reason END" + + " FROM m_voucher_correction vc" + + " WHERE vc.original_voucher_no = v.voucher_no" + + " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED', 'COMPLETED')" + + " ORDER BY vc.id DESC LIMIT 1) AS correction_note" + + " FROM m_voucher v WHERE v.voucher_no IN (" + in + ")", + nos.toArray()); + for (final Map r : rows) { + final String status; + if (r.get("revoked_by_voucher_no") != null) { + status = "REVOKED"; + } else if (r.get("correction_status") != null) { + status = (String) r.get("correction_status"); + } else if (r.get("replaces_voucher_no") != null) { + status = "CORRECTED"; + } else { + status = "ACTIVE"; + } + final Map m = new java.util.LinkedHashMap<>(); + m.put("voucherStatus", status); + m.put("revokedByVoucherNo", r.get("revoked_by_voucher_no")); + m.put("replacesVoucherNo", r.get("replaces_voucher_no")); + m.put("correctionNote", r.get("correction_note")); + marks.put((String) r.get("voucher_no"), m); + } + return marks; + } + /** * Looks up the voucher for the given request and returns its voucher_no, * or null if none exists (e.g. records created before vouchers existed). diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 53762ea3e..57c967250 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -175,4 +175,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml new file mode 100644 index 000000000..8fe23388a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0601_voucher_correction.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml new file mode 100644 index 000000000..d1aaa1cb2 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0602_voucher_drop_status_columns.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + +