Compare commits

..
Author SHA1 Message Date
niteshandClaude Opus 5.5 3a320b9c09 feat(fixed-deposits): FD funding rules and no FD withdrawals from clerk/cashier requests
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
New FixedDepositFundingValidator, used on every path money can reach an FD:
- an FD is funded once, with exactly its principal: it must be active and not matured,
  hold no deposit yet, and the amount must equal its deposit amount;
- when funded from a Savings/Current account (clerk Bank/Fund Transfer), that account
  must be a Savings or Current account, active, not debit-frozen (sub-status BLOCK /
  BLOCK_DEBIT), out of lock-in, belong to the FD's client or be held jointly with them,
  and have enough available balance after holds, lien, clearing holds and minimum
  balance (plus overdraft for current accounts).

Applied in:
- FD deposit command (counter / FD screen / approved cashier deposit requests);
- cashier deposit request submit, so the counter sees the reason at once;
- clerk Disbursement / Collection / Fund Transfer / Bank Transfer, at submit and again
  at approval before the request is locked; one request funds FDs of one customer, an
  FD appears once, and an FD can never be a debit leg. After posting, each funded FD's
  maturity date and amount are recomputed (new
  DepositAccountWritePlatformService.recalculateFixedDepositMaturity), since the direct
  book credit does not do it;
- clerk withdrawal request submit refuses an FD: money leaves an FD only through its
  maturity or premature closure.

Previously a direct book debit in Collection / Bank Transfer could take money out of an
FD, bypassing the FD's own withdrawal refusal, and an FD could be credited any number of
times for any amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Opus 5.5 8ba0420398 feat(deposits): allow several FD/RD accounts on the same product for one client
A client may now open any number of Fixed Deposit and Recurring Deposit accounts on
the same product, even while an earlier one is still active. The one-active-account-
per-product check is removed from submitFDApplication and submitRDApplication only;
the same rule on plain Savings and Current accounts is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Sonnet 5.5 6be32272c1 feat(cashier): maker-checker on cash transfer
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and
posted only when a different manager or head cashier approves it.

- POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same
  checks run again at approval, since the vault can change in between.
- Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a
  reason; the maker cannot approve their own request.
- An identical request from the same maker is refused while the first is still pending.
- The direct POST /cashtransfer is closed so the approval cannot be bypassed.
- Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:22 +05:30
niteshandClaude Sonnet 5.5 b47a34b5ef 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>
2026-10-15 12:27:21 +05:30
niteshandClaude Sonnet 5.5 04d1eb20f0 feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same
maker-checker path as a savings withdrawal:
- the request carries gl_account_id, contra_gl_id, office_id and currency_code
  (new nullable columns, changeset 0600) instead of a savings account;
- at submit it is validated: the paying GL must be a mapped cash vault GL and the
  slip's own GL must not be a cash GL;
- at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry;
- the cashier pays it against its token, and the denomination and voucher lines are
  recorded under the journal entry's id. KYC debit checks are skipped for GL requests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:01 +05:30
17 changed files with 1604 additions and 50 deletions
@@ -56,6 +56,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -98,6 +99,7 @@ public class CashierDepositRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
@@ -155,6 +157,11 @@ public class CashierDepositRequestApiResource {
throw apiError(400, "Amount must be greater than zero");
}
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
// An FD is funded once, with exactly its principal. Told here at the counter rather than when the
// manager approves; the FD deposit command checks the same again at posting.
if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) {
fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount);
}
validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue);
@@ -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) {
@@ -57,9 +57,23 @@ public class CashTransferApiResource {
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
* {@link #postTransfer}.
*/
@POST
public String create(String body) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
"A cash transfer is raised as a request and posted when a manager approves it.");
}
/**
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
* when it is approved, since the vault can change in between.
*/
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
@@ -102,6 +116,9 @@ public class CashTransferApiResource {
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
if (dryRun) {
return "{}";
}
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
@@ -112,15 +129,9 @@ public class CashTransferApiResource {
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
}
} catch (Exception e) {
// resolving the current user must never block recording the transfer
}
// Recorded against the person who raised the request, not the one who approved it.
header.setPerformedBy(performedBy);
header.setPerformedByUsername(performedByUsername);
// Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
@@ -159,6 +170,171 @@ public class CashTransferApiResource {
return gson.toJson(result);
}
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
@POST
@Path("requests")
public String submitRequest(final String body) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
postTransfer(body, user.getId(), user.getUsername(), true);
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
// a second click would otherwise release the same cash twice once both were approved.
final Integer identical = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
REQUEST_PENDING, user.getId(), body);
if (identical != null && identical > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
"An identical cash transfer request from you is already waiting for approval.");
}
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final JsonArray denominations = json.getAsJsonArray("denominations");
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_cash_transfer_request
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
status, maker_id, maker_name, maker_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_PENDING);
return gson.toJson(result);
}
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
@GET
@Path("requests")
public String listRequests(@QueryParam("status") final String status,
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
final List<Object> 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<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
for (final Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> loadRequest(final Long id) {
final List<Map<String, Object>> 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<String, Object> 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,
@@ -69,6 +69,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -90,6 +92,8 @@ public class ClerkDisbursementRequestApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ────────
@@ -202,6 +206,9 @@ public class ClerkDisbursementRequestApiResource {
}
}
// Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account.
validateFixedDepositLegs(creditEntries, requestType);
// Validate balance when both DR and CR entries present (collection mode)
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
@@ -491,6 +498,10 @@ public class ClerkDisbursementRequestApiResource {
rejectRecurringDepositLeg(entry, entryType);
}
}
// Re-checked here, not only at submit: another request (or a counter deposit) may have funded the
// same FD while this one waited, and a refusal now still leaves the request PENDING.
final Set<Long> fundedFixedDeposits = validateFixedDepositLegs(creditEntries,
record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT");
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
// posting duplicate Fineract transactions for the same request.
@@ -773,6 +784,12 @@ public class ClerkDisbursementRequestApiResource {
}
} // end else (disbursement/collection mode)
// A funded FD's maturity date and amount were projected off its principal with no money in it;
// the direct credit above does not recompute them, so do it now that the money has landed.
for (final Long fdId : fundedFixedDeposits) {
depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId);
}
} catch (Exception ex) {
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
// This restores account balances even when a later entry fails mid-processing.
@@ -1223,6 +1240,70 @@ public class ClerkDisbursementRequestApiResource {
}
}
/**
* Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label.
* <ul>
* <li>An FD is never debited here: money leaves an FD only through its maturity or premature closure. The
* direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.</li>
* <li>An FD credit is its first and only funding, for exactly its principal, and an FD appears once.</li>
* <li>The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund
* them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is
* the bank's own money and has no customer to compare.</li>
* </ul>
*
* @return the ids of the FDs this request funds, for the maturity recalculation after posting
*/
private Set<Long> validateFixedDepositLegs(final JsonArray entries, final String requestType) {
final Set<Long> fdIds = new LinkedHashSet<>();
Long firstFdId = null;
Long fdClientId = null;
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) {
continue;
}
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId);
if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) {
throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only"
+ " through its maturity or premature closure.");
}
if (!fdIds.add(accountId)) {
throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once,"
+ " with its full principal in one entry.");
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount);
if (firstFdId == null) {
firstFdId = accountId;
fdClientId = clientId;
} else if (!Objects.equals(fdClientId, clientId)) {
throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer"
+ " only. Raise a separate request for each customer.");
}
}
if (fdIds.isEmpty()) {
return fdIds;
}
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type)
|| !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(),
"CURRENT".equals(type), amount);
}
return fdIds;
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -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;
@@ -56,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -84,6 +86,7 @@ public class ClerkWithdrawalRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
@@ -110,13 +113,28 @@ 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");
}
// Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip.
// Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account.
if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) {
throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from."
+ " Money leaves a fixed deposit only through its maturity or premature closure.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero");
}
@@ -124,7 +142,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 +152,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 +163,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 +221,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 +276,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 +301,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 +321,8 @@ public class ClerkWithdrawalRequestApiResource {
final List<Map<String, Object>> 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 +482,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 +499,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 +511,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 +727,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 +831,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<Long> 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<String, Object> gl = glAccountForVoucher(glId);
final Map<String, Object> 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<String, Object> 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<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
@@ -800,7 +949,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()) {
@@ -392,6 +392,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
// An FD is funded once, with exactly its principal (see FixedDepositFundingValidator).
new FixedDepositFundingValidator(this.jdbcTemplate).validateFirstFunding(savingsId, transactionAmount);
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validatePreviousDayClosed();
@@ -422,6 +425,20 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Transactional
@Override
public void recalculateFixedDepositMaturity(final Long savingsId) {
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@Transactional
@Override
public CommandProcessingResult updateDepositAmountForRDAccount(Long savingsId, JsonCommand command) {
@@ -48,7 +48,6 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.event.business.domain.deposit.FixedDepositAccountCreateBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.deposit.RecurringDepositAccountCreateBusinessEvent;
@@ -184,16 +183,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy,
DepositAccountType.FIXED_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Fixed Deposit account with the same product.",
"productId", account.productId());
}
}
// A client may hold any number of Fixed Deposits on the same product, even while an earlier one is
// still active, so there is deliberately no one-active-account-per-product check here.
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
@@ -269,16 +260,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(command,
submittedBy, DepositAccountType.RECURRING_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Recurring Deposit account with the same product.",
"productId", account.productId());
}
}
// A client may hold any number of Recurring Deposits on the same product, even while an earlier one
// is still active, so there is deliberately no one-active-account-per-product check here.
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
@@ -0,0 +1,238 @@
/**
* 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.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The rules a fixed deposit is funded under, shared by every path money can reach an FD by: the FD deposit
* command itself (cash at the counter, approved cashier deposit requests) and the clerk's Bank Transfer,
* Fund Transfer and Disbursement requests, which credit the account directly.
*
* <p>
* An FD opens with no money in it and is funded exactly once, with exactly its principal. A second credit
* would silently change the principal the maturity was agreed on; anything more goes into a new FD (several
* FDs on the same product are allowed). Money never leaves an FD through these paths either - only through
* its maturity or premature closure.
*
* <p>
* When the money comes from the customer's own Savings or Current account, that account must be able to pay
* it: active, not debit-frozen, out of any lock-in, belonging to the FD's client (or held jointly with them),
* and holding enough available balance once holds, liens, clearing holds and its minimum balance are set
* aside. The KYC debit restriction on that account is checked by the callers through KycRestrictionGuard.
*/
@Component
@RequiredArgsConstructor
public class FixedDepositFundingValidator {
/** m_savings_account.deposit_type_enum (Fineract DepositAccountType). */
public static final int DEPOSIT_TYPE_SAVINGS = 100;
public static final int DEPOSIT_TYPE_FIXED = 200;
private static final int STATUS_ACTIVE = 300;
private static final int TXN_TYPE_DEPOSIT = 1;
/** SavingsAccountSubStatusEnum.BLOCK (no debits or credits) and BLOCK_DEBIT. */
private static final int SUB_STATUS_BLOCK = 400;
private static final int SUB_STATUS_BLOCK_DEBIT = 600;
private final JdbcTemplate jdbcTemplate;
/** Whether this m_savings_account id is a fixed deposit (a CURRENT account id must not be passed here). */
public boolean isFixedDeposit(final Long savingsAccountId) {
if (savingsAccountId == null) {
return false;
}
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
Integer.class, savingsAccountId, DEPOSIT_TYPE_FIXED);
return count != null && count > 0;
}
/**
* The FD's first and only funding: the account must be an active, not yet matured fixed deposit with no
* money in it, and the amount must be exactly its principal.
*
* @return the FD's client id, for the source-account checks
*/
public Long validateFirstFunding(final Long fdAccountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT s.account_no, s.status_enum, s.deposit_type_enum, s.client_id, s.account_balance_derived,
t.deposit_amount, t.maturity_date
FROM m_savings_account s
LEFT JOIN m_deposit_account_term_and_preclosure t ON t.savings_account_id = s.id
WHERE s.id = ?
""", fdAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.account.not.found", "Fixed deposit account " + fdAccountId + " was not found.");
}
final Map<String, Object> fd = rows.get(0);
final String accountNo = (String) fd.get("account_no");
if (intOf(fd.get("deposit_type_enum")) != DEPOSIT_TYPE_FIXED) {
throw fail("error.msg.fd.funding.not.a.fixed.deposit", "Account " + accountNo + " is not a fixed deposit.");
}
if (intOf(fd.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.not.active",
"Fixed deposit " + accountNo + " is not active. Only an active fixed deposit can be funded.");
}
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final LocalDate maturityDate = fd.get("maturity_date") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (maturityDate != null && !businessDate.isBefore(maturityDate)) {
throw fail("error.msg.fd.funding.matured",
"Fixed deposit " + accountNo + " has reached its maturity date (" + maturityDate + ") and can no longer be funded.");
}
final Integer deposits = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE savings_account_id = ? AND transaction_type_enum = ?"
+ " AND is_reversed = false",
Integer.class, fdAccountId, TXN_TYPE_DEPOSIT);
if ((deposits != null && deposits > 0) || amountOf(fd.get("account_balance_derived")).signum() > 0) {
throw fail("error.msg.fd.funding.already.funded", "Fixed deposit " + accountNo
+ " is already funded. A fixed deposit is funded only once - open a new fixed deposit for more money.");
}
final BigDecimal principal = fd.get("deposit_amount") == null ? null : amountOf(fd.get("deposit_amount"));
if (principal == null || principal.signum() <= 0) {
throw fail("error.msg.fd.funding.principal.missing", "Fixed deposit " + accountNo + " has no deposit amount (principal) set.");
}
if (amount == null || amount.compareTo(principal) != 0) {
throw fail("error.msg.fd.funding.amount.mismatch", "Amount " + (amount == null ? "0" : amount.toPlainString())
+ " for fixed deposit " + accountNo + " must be exactly its principal " + principal.toPlainString() + " - not less, not more.");
}
return fd.get("client_id") == null ? null : ((Number) fd.get("client_id")).longValue();
}
/**
* The Savings or Current account an FD is being funded from.
*
* @param fdAccountId
* the FD being funded
* @param fdClientId
* its client, from {@link #validateFirstFunding}
* @param sourceAccountId
* m_savings_account id, or m_current_account id when {@code sourceIsCurrent}
*/
public void validateFundingSource(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final BigDecimal amount) {
final List<Map<String, Object>> rows = sourceIsCurrent ? jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, account_balance_derived, on_hold_funds_derived,
total_savings_amount_on_hold, min_required_balance, enforce_min_required_balance,
allow_overdraft, overdraft_limit, lockedin_until_date_derived
FROM m_current_account WHERE id = ?
""", sourceAccountId) : jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, deposit_type_enum, account_balance_derived,
on_hold_funds_derived, total_savings_amount_on_hold, total_savings_amount_on_hold_clearing,
min_required_balance, enforce_min_required_balance, lockedin_until_date_derived
FROM m_savings_account WHERE id = ?
""", sourceAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.source.not.found", "Source account " + sourceAccountId + " was not found.");
}
final Map<String, Object> src = rows.get(0);
final String accountNo = (String) src.get("account_no");
if (!sourceIsCurrent && intOf(src.get("deposit_type_enum")) != DEPOSIT_TYPE_SAVINGS) {
throw fail("error.msg.fd.funding.source.type",
"Account " + accountNo + " cannot fund a fixed deposit - only a Savings or Current account can.");
}
if (intOf(src.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.source.not.active", "Source account " + accountNo + " is not active.");
}
final int subStatus = intOf(src.get("sub_status_enum"));
if (subStatus == SUB_STATUS_BLOCK || subStatus == SUB_STATUS_BLOCK_DEBIT) {
throw fail("error.msg.fd.funding.source.debit.frozen", "Source account " + accountNo + " is blocked for debits (debit freeze).");
}
final LocalDate lockedUntil = src.get("lockedin_until_date_derived") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (lockedUntil != null && DateUtils.getBusinessLocalDate().isBefore(lockedUntil)) {
throw fail("error.msg.fd.funding.source.locked.in", "Source account " + accountNo + " is locked-in until " + lockedUntil + ".");
}
final Long sourceClientId = src.get("client_id") == null ? null : ((Number) src.get("client_id")).longValue();
if (!sameClientOrJointHolder(fdAccountId, fdClientId, sourceAccountId, sourceIsCurrent, sourceClientId)) {
throw fail("error.msg.fd.funding.source.other.client", "Source account " + accountNo
+ " does not belong to the fixed deposit's customer. Fund an FD only from the customer's own account"
+ " or one they hold jointly.");
}
BigDecimal available = amountOf(src.get("account_balance_derived")).subtract(amountOf(src.get("on_hold_funds_derived")))
.subtract(amountOf(src.get("total_savings_amount_on_hold")));
if (!sourceIsCurrent) {
available = available.subtract(amountOf(src.get("total_savings_amount_on_hold_clearing")));
}
if (Boolean.TRUE.equals(src.get("enforce_min_required_balance"))) {
available = available.subtract(amountOf(src.get("min_required_balance")));
}
if (sourceIsCurrent && Boolean.TRUE.equals(src.get("allow_overdraft"))) {
available = available.add(amountOf(src.get("overdraft_limit")));
}
if (amount == null || available.compareTo(amount) < 0) {
throw fail("error.msg.fd.funding.source.insufficient", "Source account " + accountNo + " has an available balance of "
+ available.max(BigDecimal.ZERO).toPlainString() + " (after holds, lien and minimum balance), less than "
+ (amount == null ? "0" : amount.toPlainString()) + ".");
}
}
private boolean sameClientOrJointHolder(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final Long sourceClientId) {
if (fdClientId == null) {
return false;
}
if (Objects.equals(fdClientId, sourceClientId)) {
return true;
}
// The FD's customer is a joint holder on the source account ...
final Integer onSource = sourceIsCurrent
? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_current_account_joint_holder WHERE current_account_id = ? AND client_id = ?",
Integer.class, sourceAccountId, fdClientId)
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
sourceAccountId, fdClientId);
if (onSource != null && onSource > 0) {
return true;
}
// ... or the source account's customer is a joint holder on the FD.
if (sourceClientId == null) {
return false;
}
final Integer onFd = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
fdAccountId, sourceClientId);
return onFd != null && onFd > 0;
}
private static int intOf(final Object value) {
return value instanceof Number n ? n.intValue() : -1;
}
private static BigDecimal amountOf(final Object value) {
return value instanceof BigDecimal b ? b : value instanceof Number n ? new BigDecimal(n.toString()) : BigDecimal.ZERO;
}
private static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -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).
@@ -174,4 +174,8 @@
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
<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"/>
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) goes through the same request, manager approval and
cashier disbursement as a savings withdrawal, so the request carries the GL account and the cash GL it is paid
from. All four columns are nullable: savings, current and deposit requests do not use them.
-->
<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="0600_clerk_withdrawal_request_add_gl_columns" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_clerk_withdrawal_request"/>
<not><columnExists tableName="m_clerk_withdrawal_request" columnName="gl_account_id"/></not>
</preConditions>
<addColumn tableName="m_clerk_withdrawal_request">
<column name="gl_account_id" type="BIGINT"/>
<column name="contra_gl_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="currency_code" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
</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>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Cash Transfer is maker-checker: the person who allocates cash from the vault to a cashier raises a request, and the
allocation is posted only when a different person approves it. m_cash_transfer stays the table of posted transfers;
this table holds the requests, with the whole request body so the approval posts exactly what was asked for.
-->
<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="0603_create_cash_transfer_request" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cash_transfer_request"/></not>
</preConditions>
<createTable tableName="m_cash_transfer_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_date" type="DATE"/>
<column name="from_teller_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="to_cashier_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="from_party" type="VARCHAR(255)"/>
<column name="to_party" type="VARCHAR(255)"/>
<column name="note" type="VARCHAR(1000)"/>
<column name="total_amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="request_body" type="TEXT"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="maker_id" type="BIGINT"/>
<column name="maker_name" type="VARCHAR(255)"/>
<column name="maker_at" type="TIMESTAMP"/>
<column name="checker_id" type="BIGINT"/>
<column name="checker_name" type="VARCHAR(255)"/>
<column name="checker_at" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="cash_transfer_id" type="BIGINT"/>
</createTable>
<createIndex tableName="m_cash_transfer_request" indexName="idx_cash_transfer_request_status">
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -39,6 +39,13 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult depositToFDAccount(Long savingsId, JsonCommand command);
/**
* Recomputes a fixed deposit's maturity date and amount from its real transaction history. Used after an FD
* is funded by a direct book credit (clerk Bank Transfer / Fund Transfer / Disbursement), which does not go
* through {@link #depositToFDAccount}.
*/
void recalculateFixedDepositMaturity(Long savingsId);
CommandProcessingResult depositToRDAccount(Long savingsId, JsonCommand command);
CommandProcessingResult withdrawal(Long savingsId, JsonCommand command, DepositAccountType depositAccountType);