From 6be32272c1c9da48f67aeb6b0a4566a444863875 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 15 Oct 2026 12:27:22 +0530 Subject: [PATCH] feat(cashier): maker-checker on cash transfer Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and posted only when a different manager or head cashier approves it. - POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same checks run again at approval, since the vault can change in between. - Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a reason; the maker cannot approve their own request. - An identical request from the same maker is refused while the first is still pending. - The direct POST /cashtransfer is closed so the approval cannot be bypassed. - Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../api/CashTransferApiResource.java | 196 +++++++++++++++++- .../tenant/final-changelog-tenant.xml | 1 + .../parts/0603_cash_transfer_request.xml | 46 ++++ 3 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java index 437999967..3c87ac908 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashtransfer/api/CashTransferApiResource.java @@ -57,9 +57,23 @@ public class CashTransferApiResource { private final VaultPositionService vaultPositionService; private final Gson gson = new Gson(); + /** + * Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see + * {@link #submitRequest}) and only a different person's approval posts it. The posting itself is + * {@link #postTransfer}. + */ @POST public String create(String body) { - this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER"); + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required", + "A cash transfer is raised as a request and posted when a manager approves it."); + } + + /** + * Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry. + * A dry run stops after every check has passed, which is what a request is held to when it is raised and again + * when it is approved, since the vault can change in between. + */ + private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) { JsonObject json = JsonParser.parseString(body).getAsJsonObject(); final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull() @@ -102,6 +116,9 @@ public class CashTransferApiResource { } validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate); + if (dryRun) { + return "{}"; + } CashTransfer header = new CashTransfer(); header.setTransferDate(transferDate); @@ -112,15 +129,9 @@ public class CashTransferApiResource { header.setTransferAmount(total); header.setNote(note); header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); - try { - AppUser user = this.context.authenticatedUser(); - if (user != null) { - header.setPerformedBy(user.getId()); - header.setPerformedByUsername(user.getUsername()); - } - } catch (Exception e) { - // resolving the current user must never block recording the transfer - } + // Recorded against the person who raised the request, not the one who approved it. + header.setPerformedBy(performedBy); + header.setPerformedByUsername(performedByUsername); // Save header + details in one committed transaction, then GL dispatch separately. // This prevents EclipseLink from flushing pending detail rows inside Fineract's @@ -159,6 +170,171 @@ public class CashTransferApiResource { return gson.toJson(result); } + // ── Maker-checker: the allocation is a request until a different person approves it ──────────── + + /** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */ + @POST + @Path("requests") + public String submitRequest(final String body) { + final AppUser user = this.context.authenticatedUser(); + user.validateHasPermissionTo("CREATE_CASHTRANSFER"); + postTransfer(body, user.getId(), user.getUsername(), true); + + // The same request, from the same person, is not raised twice while the first is still waiting: a second tap or + // a second click would otherwise release the same cash twice once both were approved. + final Integer identical = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class, + REQUEST_PENDING, user.getId(), body); + if (identical != null && identical > 0) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate", + "An identical cash transfer request from you is already waiting for approval."); + } + + final JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + final JsonArray denominations = json.getAsJsonArray("denominations"); + final Long id = jdbcTemplate.queryForObject(""" + INSERT INTO m_cash_transfer_request + (transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body, + status, maker_id, maker_name, maker_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + RETURNING id + """, Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")), + json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"), + textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(), + user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now())); + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_PENDING); + return gson.toJson(result); + } + + /** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */ + @GET + @Path("requests") + public String listRequests(@QueryParam("status") final String status, + @QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) { + final AppUser user = this.context.authenticatedUser(); + final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1"); + final List params = new java.util.ArrayList<>(); + if (status != null && !status.isBlank()) { + final String[] statuses = status.split(","); + sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")"); + for (final String s : statuses) { + params.add(s.trim().toUpperCase()); + } + } + if (mine) { + sql.append(" AND maker_id = ?"); + params.add(user.getId()); + } + sql.append(" ORDER BY id DESC LIMIT 200"); + final List> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray()); + for (final Map row : rows) { + // The notes asked for, so the approver sees what they are releasing without opening anything. + final Object requestBody = row.remove("request_body"); + row.put("denominations", requestBody == null ? new JsonArray() + : JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations")); + } + return gson.toJson(rows); + } + + /** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */ + @POST + @Path("requests/{id}/approve") + public String approveRequest(@PathParam("id") final Long id) { + final AppUser user = this.context.authenticatedUser(); + requireChecker(user); + final Map r = loadRequest(id); + requirePending(r, "approved"); + final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null; + if (makerId != null && makerId.equals(user.getId())) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user", + "Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager."); + } + final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false); + final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject(); + final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null; + + final int updated = jdbcTemplate.update(""" + UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ? + WHERE id = ? AND status = ? + """, REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()), + transferId, id, REQUEST_PENDING); + if (updated == 0) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned", + "This request was already actioned by another manager."); + } + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_APPROVED); + if (transferId != null) { + result.addProperty("cashTransferId", transferId); + } + if (postedJson.has("glWarning")) { + result.add("glWarning", postedJson.get("glWarning")); + } + return gson.toJson(result); + } + + @POST + @Path("requests/{id}/reject") + public String rejectRequest(@PathParam("id") final Long id, final String body) { + final AppUser user = this.context.authenticatedUser(); + requireChecker(user); + final Map r = loadRequest(id); + requirePending(r, "rejected"); + final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject(); + final String reason = textOrNull(json, "reason"); + if (reason == null || reason.isBlank()) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject."); + } + jdbcTemplate.update(""" + UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ? + WHERE id = ? AND status = ? + """, REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()), + reason.trim(), id, REQUEST_PENDING); + final JsonObject result = new JsonObject(); + result.addProperty("resourceId", id); + result.addProperty("status", REQUEST_REJECTED); + return gson.toJson(result); + } + + private static final String REQUEST_PENDING = "PENDING"; + private static final String REQUEST_APPROVED = "APPROVED"; + private static final String REQUEST_REJECTED = "REJECTED"; + + private Map loadRequest(final Long id) { + final List> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id); + if (rows.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found."); + } + return rows.get(0); + } + + private void requirePending(final Map request, final String action) { + if (!REQUEST_PENDING.equals(request.get("status"))) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status", + "Only PENDING requests can be " + action + ". Current status: " + request.get("status")); + } + } + + /** Checker authorization, the same rule the withdrawal and deposit request registers use. */ + private void requireChecker(final AppUser user) { + if (user.hasAnyPermission("ALL_FUNCTIONS")) { + return; + } + final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim()) + .anyMatch(n -> n.contains("manager") || n.contains("head cashier")); + if (!isChecker) { + throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker", + "Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer."); + } + } + + private static String textOrNull(final JsonObject json, final String key) { + return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null; + } + @GET public String getAll( @QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset, diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 57c967250..959cdfcfa 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -177,4 +177,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml new file mode 100644 index 000000000..5d88f52d8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0603_cash_transfer_request.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +