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 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:27:21 +05:30
co-authored by Claude Sonnet 5.5
parent 04d1eb20f0
commit b47a34b5ef
7 changed files with 822 additions and 3 deletions
@@ -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<String, Map<String, Object>> marks = voucherService.voucherMarks(
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
for (final Map<String, Object> row : rows) {
final Map<String, Object> 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) {
@@ -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.
*
* <ol>
* <li>The cashier picks the day's voucher and asks to <b>unpass</b> it (UNPASS_PENDING). Nothing moves.</li>
* <li>A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply
* waiting for its correction entry.</li>
* <li>The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number
* (CORRECTION_PENDING).</li>
* <li>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.</li>
* </ol>
*
* 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<Object> 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<Map<String, Object>> 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<String, Object> 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<Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> r = load(id);
requireStatus(r, UNPASSED, "corrected");
final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body));
final Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> validateCorrectAccount(final Map<String, Object> r, final String accountNo) {
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "The correct account number is required");
}
final Map<String, Object> 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<String, Object> 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<String, Object> findSavingsAccount(final String accountNo) {
if (accountNo == null || accountNo.isBlank()) {
return null;
}
final List<Map<String, Object>> 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<String, Object> 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<VoucherLine> lines = new ArrayList<>();
for (final Map<String, Object> 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<String, Object> load(final Long id) {
final List<Map<String, Object>> 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<String, Object> 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());
}
}
@@ -628,9 +628,15 @@ public class VoucherVerificationApiResource {
BigDecimal total = BigDecimal.ZERO;
final List<Map<String, Object>> out = new java.util.ArrayList<>();
final Map<String, Map<String, Object>> marks = voucherService
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
for (final Map<String, Object> r : rows) {
final BigDecimal amount = (BigDecimal) r.get("amount");
if (amount != null) {
final Map<String, Object> 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<Map<String, Object>> out = new java.util.ArrayList<>();
final Map<String, Map<String, Object>> myWorkMarks = voucherService
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
for (final Map<String, Object> 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<String, Object> 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<String, Object> row, final Map<String, Object> 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);
@@ -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<String, Map<String, Object>> voucherMarks(final java.util.Collection<String> voucherNos) {
final Map<String, Map<String, Object>> marks = new java.util.HashMap<>();
final List<String> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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).
@@ -175,4 +175,6 @@
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
<include file="parts/0600_clerk_withdrawal_request_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Voucher correction: a cash deposit posted to the wrong savings account is unpassed, approved, re-entered against
the right account by the cashier and approved again. m_voucher_correction carries that request through its steps;
m_voucher gains a status so the original voucher can be REVOKED once its replacement exists.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0601_voucher_add_status" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_voucher" columnName="status"/></not>
</preConditions>
<addColumn tableName="m_voucher">
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="revoked_at" type="TIMESTAMP"/>
<column name="revoked_by_voucher_no" type="VARCHAR(30)"/>
</addColumn>
</changeSet>
<changeSet id="0601_create_voucher_correction" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_voucher_correction"/></not>
</preConditions>
<createTable tableName="m_voucher_correction">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="original_voucher_no" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="original_txn_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="wrong_savings_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="wrong_account_no" type="VARCHAR(50)"/>
<column name="wrong_client_name" type="VARCHAR(255)"/>
<column name="amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="payment_type_id" type="BIGINT"/>
<column name="txn_date" type="DATE"/>
<column name="status" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="reason" type="VARCHAR(1000)"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="unpass_by_id" type="BIGINT"/>
<column name="unpass_by_name" type="VARCHAR(255)"/>
<column name="unpass_at" type="TIMESTAMP"/>
<column name="unpass_approved_by_id" type="BIGINT"/>
<column name="unpass_approved_by_name" type="VARCHAR(255)"/>
<column name="unpass_approved_at" type="TIMESTAMP"/>
<column name="correct_savings_account_id" type="BIGINT"/>
<column name="correct_account_no" type="VARCHAR(50)"/>
<column name="correct_client_id" type="BIGINT"/>
<column name="correct_client_name" type="VARCHAR(255)"/>
<column name="correction_by_id" type="BIGINT"/>
<column name="correction_by_name" type="VARCHAR(255)"/>
<column name="correction_at" type="TIMESTAMP"/>
<column name="correction_approved_by_id" type="BIGINT"/>
<column name="correction_approved_by_name" type="VARCHAR(255)"/>
<column name="correction_approved_at" type="TIMESTAMP"/>
<column name="new_voucher_no" type="VARCHAR(30)"/>
<column name="new_txn_id" type="BIGINT"/>
<column name="activity_log" type="TEXT"/>
</createTable>
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_voucher">
<column name="original_voucher_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
The voucher itself carries no correction status: whether a voucher was unpassed, revoked or closed is read from
m_voucher_correction. 0601 had added status, revoked_at and revoked_by_voucher_no to m_voucher; nothing writes them
any more, so they are dropped and the voucher table is back to exactly what the registers raised.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0602_voucher_drop_status_columns" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_voucher" columnName="status"/>
</preConditions>
<dropColumn tableName="m_voucher" columnName="status"/>
<dropColumn tableName="m_voucher" columnName="revoked_at"/>
<dropColumn tableName="m_voucher" columnName="revoked_by_voucher_no"/>
</changeSet>
</databaseChangeLog>