feat(cashier+voucher): add cashier deposit maker-checker flow and central voucher system
- VoucherService: central voucher register with globally unique voucher numbers - CashierDepositRequestApiResource: full submit/approve/reject/list flow - Cashier submits deposit request; balance credited only on manager approval - Self-approval blocked (maker cannot be checker) - Role guard: only manager/head-cashier/ALL_FUNCTIONS can approve/reject - Race condition guard: WHERE status='PENDING' on approve/reject UPDATE - Clean JSON error responses (no 500 stack traces) via apiError() helper - Denomination total validation at submit and approve - ClerkWithdrawalRequestApiResource: refactored to use m_voucher - Voucher created at submit, linked to savings txn at approve - Same role guard and self-approval block as deposit flow - Disburse: denomination validation, null denoms for non-cash payments - Migrations 0327-0330: - 0327: add voucher_no to m_clerk_withdrawal_request - 0328: add voucher_no to m_savings_account_transaction - 0329: create m_voucher central table (globally unique voucher_no) - 0330: create m_cashier_deposit_request table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
8d5b4d1ce9
commit
33ea10689f
+636
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* 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.cashierdeposit.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.PUT;
|
||||
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.Date;
|
||||
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 java.util.Optional;
|
||||
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.security.service.PlatformSecurityContext;
|
||||
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;
|
||||
|
||||
@Path("/v1/cashier/deposit-requests")
|
||||
@Component
|
||||
@Tag(name = "Cashier Deposit Requests", description = "Maker-checker deposit request workflow: cashier submits, bank manager approves.")
|
||||
@RequiredArgsConstructor
|
||||
public class CashierDepositRequestApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final VoucherService voucherService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String submit(final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
|
||||
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
|
||||
final String accountType = Optional.ofNullable(fromJsonHelper.extractStringNamed("accountType", root)).orElse("SAVINGS");
|
||||
final Long savingsAccountId = fromJsonHelper.extractLongNamed("savingsAccountId", root);
|
||||
final Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
|
||||
final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
|
||||
final BigDecimal amount = parseBigDecimal(fromJsonHelper.extractStringNamed("amount", root));
|
||||
final Long paymentTypeId = fromJsonHelper.extractLongNamed("paymentTypeId", root);
|
||||
final String paymentTypeName = fromJsonHelper.extractStringNamed("paymentTypeName", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final String chequeNo = fromJsonHelper.extractStringNamed("chequeNo", root);
|
||||
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
|
||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw apiError(400, "Account number is required");
|
||||
}
|
||||
if (savingsAccountId == null) {
|
||||
throw apiError(400, "Savings account id is required for a deposit request");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw apiError(400, "Amount must be greater than zero");
|
||||
}
|
||||
validateDenominationTotal(denominationData, amount);
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_cashier_deposit_request
|
||||
(account_no, account_type, savings_account_id, client_id, client_name, amount,
|
||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date,
|
||||
status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
accountNo, accountType, savingsAccountId, clientId, clientName, amount,
|
||||
paymentTypeId, paymentTypeName, description, chequeNo, bankName,
|
||||
txnDate != null ? Date.valueOf(txnDate) : null,
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
denominationData, activityLog, Timestamp.from(Instant.now())
|
||||
);
|
||||
|
||||
// Globally unique voucher number from the central m_voucher register
|
||||
final String voucherNo = voucherService.createVoucher(
|
||||
VoucherService.TYPE_DEPOSIT, VoucherService.REF_CASHIER_DEPOSIT_REQUEST, newId, amount, currentUser);
|
||||
jdbcTemplate.update("UPDATE m_cashier_deposit_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
||||
}
|
||||
|
||||
// ── GET /v1/cashier/deposit-requests ── Manager sees all requests ────────────
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(
|
||||
@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
baseParams.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
baseParams.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cashier_deposit_request " + where, Long.class, baseParams.toArray())
|
||||
).orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(baseParams);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, voucher_no, account_no, account_type, savings_account_id, client_id, client_name,"
|
||||
+ " amount, payment_type_id, payment_type_name, description, cheque_no, bank_name,"
|
||||
+ " txn_date, status, cashier_id, cashier_name, cashier_username,"
|
||||
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
||||
+ " denomination_data, activity_log, submitted_at, actioned_at"
|
||||
+ " FROM m_cashier_deposit_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalFilteredRecords", total);
|
||||
result.add("pageItems", gson.toJsonTree(rows));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/cashier/deposit-requests/my ── Cashier's own requests ────────────
|
||||
|
||||
@GET
|
||||
@Path("/my")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String myRequests(
|
||||
@QueryParam("status") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final StringBuilder where = new StringBuilder("WHERE cashier_id = ?");
|
||||
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
|
||||
|
||||
if (status != null && !status.isBlank()) {
|
||||
where.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
params.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
params.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cashier_deposit_request " + where, Long.class, params.toArray())
|
||||
).orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(params);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"""
|
||||
SELECT id, voucher_no, account_no, account_type, client_name, amount,
|
||||
payment_type_name, description, txn_date, status,
|
||||
cashier_name, manager_name, manager_username, rejection_reason, txn_id,
|
||||
submitted_at, actioned_at
|
||||
FROM m_cashier_deposit_request
|
||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray()
|
||||
);
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("totalFilteredRecords", total);
|
||||
result.add("pageItems", gson.toJsonTree(rows));
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── GET /v1/cashier/deposit-requests/{id} ── Full detail ─────────────────────
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getById(@PathParam("id") final Long id) {
|
||||
return gson.toJson(loadRecord(id));
|
||||
}
|
||||
|
||||
// ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ──
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String update(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
if (!"REJECTED".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only REJECTED requests can be edited. Current status: " + record.get("status"));
|
||||
}
|
||||
// Only the cashier who created the request may resubmit it
|
||||
final Long ownerId = ((Number) record.get("cashier_id")).longValue();
|
||||
if (!ownerId.equals(currentUser.getId())) {
|
||||
throw apiError(403, "Only the cashier who submitted this deposit request can edit it.");
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final BigDecimal amount = parseBigDecimal(fromJsonHelper.extractStringNamed("amount", root));
|
||||
final Long paymentTypeId = fromJsonHelper.extractLongNamed("paymentTypeId", root);
|
||||
final String paymentTypeName = fromJsonHelper.extractStringNamed("paymentTypeName", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final String chequeNo = fromJsonHelper.extractStringNamed("chequeNo", root);
|
||||
final String bankName = fromJsonHelper.extractStringNamed("bankName", root);
|
||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw apiError(400, "Amount must be greater than zero");
|
||||
}
|
||||
validateDenominationTotal(denominationData, amount);
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "RESUBMITTED", currentUser.getUsername(), null
|
||||
);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_cashier_deposit_request SET
|
||||
amount = ?, payment_type_id = ?, payment_type_name = ?,
|
||||
description = ?, cheque_no = ?, bank_name = ?, txn_date = ?,
|
||||
denomination_data = ?, status = 'PENDING',
|
||||
rejection_reason = NULL, manager_id = NULL, manager_name = NULL, manager_username = NULL,
|
||||
activity_log = ?, submitted_at = ?, actioned_at = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
amount, paymentTypeId, paymentTypeName,
|
||||
description, chequeNo, bankName,
|
||||
txnDate != null ? Date.valueOf(txnDate) : null,
|
||||
denominationData,
|
||||
updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
|
||||
// Keep the central voucher register in sync with the edited amount
|
||||
voucherService.updateAmount(VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, amount);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "PENDING",
|
||||
"voucherNo", Optional.ofNullable(record.get("voucher_no")).map(Object::toString).orElse("")));
|
||||
}
|
||||
|
||||
// ── POST /v1/cashier/deposit-requests/{id}/approve ── Manager approves ───────
|
||||
|
||||
@POST
|
||||
@Path("/{id}/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approve(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||
}
|
||||
// Maker ≠ checker: a manager must not approve their own deposit request
|
||||
final Long ownerId = ((Number) record.get("cashier_id")).longValue();
|
||||
if (ownerId.equals(currentUser.getId())) {
|
||||
throw apiError(403,
|
||||
"Maker-checker: you cannot approve a deposit request you submitted yourself. Ask another manager to review it.");
|
||||
}
|
||||
|
||||
final Long savingsAccountId = ((Number) record.get("savings_account_id")).longValue();
|
||||
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
||||
final Object ptId = record.get("payment_type_id");
|
||||
final Object txnDateObj = record.get("txn_date");
|
||||
final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null;
|
||||
final String description = (String) record.get("description");
|
||||
|
||||
final String txnId = executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
|
||||
// Link voucher to the created savings txn and stamp voucher_no on it
|
||||
if (txnId != null) {
|
||||
voucherService.linkSavingsTransaction(
|
||||
VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, Long.valueOf(txnId));
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||
);
|
||||
|
||||
// WHERE status = 'PENDING' guards against two managers approving at once;
|
||||
// @Transactional rolls back the savings deposit if we lose the race.
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_cashier_deposit_request SET
|
||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
txn_id = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
txnId, updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
if (updated == 0) {
|
||||
throw apiError(409, "This deposit request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
// Record denominations so the CASHIER's vault position reflects cash taken in.
|
||||
// Officer = the cashier who physically received the cash, not the approving manager.
|
||||
final String denominationData = (String) record.get("denomination_data");
|
||||
if (txnId != null && denominationData != null && !denominationData.isBlank()) {
|
||||
saveDepositDenomination(denominationData, record, Long.valueOf(txnId));
|
||||
}
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
res.addProperty("status", "APPROVED");
|
||||
if (txnId != null) res.addProperty("txnId", txnId);
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
// ── POST /v1/cashier/deposit-requests/{id}/reject ── Manager rejects ─────────
|
||||
|
||||
@POST
|
||||
@Path("/{id}/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw apiError(400, "Rejection reason is required");
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason
|
||||
);
|
||||
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_cashier_deposit_request SET
|
||||
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
rejection_reason = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
reason, updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
if (updated == 0) {
|
||||
throw apiError(409, "This deposit request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, voucher_no, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
amount, payment_type_id, payment_type_name, description, cheque_no, bank_name,
|
||||
txn_date, status, cashier_id, cashier_name, cashier_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||
denomination_data, activity_log, submitted_at, actioned_at
|
||||
FROM m_cashier_deposit_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Deposit request not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private String executeSavingsDeposit(
|
||||
final Long savingsAccountId, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("note", note);
|
||||
}
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder()
|
||||
.savingsAccountDeposit(savingsAccountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the denomination breakdown the cashier entered at submit time into
|
||||
* m_savings_txn_denomination once the deposit is approved, so the cashier's
|
||||
* vault position report reflects the cash received at the counter.
|
||||
* denominationData format: {"note:500": 2, "coin:1": 10} (kind:value → count)
|
||||
*/
|
||||
private void saveDepositDenomination(final String denominationData,
|
||||
final Map<?, ?> record, final Long txnId) {
|
||||
final JsonObject denomMap;
|
||||
try {
|
||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||
} catch (Exception e) {
|
||||
return; // malformed JSON — skip silently
|
||||
}
|
||||
if (denomMap == null || denomMap.size() == 0) return;
|
||||
|
||||
final Object saObj = record.get("savings_account_id");
|
||||
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
|
||||
final String accountNo = (String) record.get("account_no");
|
||||
final Object clientIdObj = record.get("client_id");
|
||||
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
|
||||
final Object cashierIdObj = record.get("cashier_id");
|
||||
final Long cashierId = cashierIdObj != null ? Long.valueOf(cashierIdObj.toString()) : null;
|
||||
final String cashierUsername = (String) record.get("cashier_username");
|
||||
|
||||
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
||||
final int count;
|
||||
try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; }
|
||||
if (count <= 0) continue;
|
||||
|
||||
final String[] parts = entry.getKey().split(":", 2); // "note:500" or "coin:1"
|
||||
if (parts.length != 2) continue;
|
||||
final String kind = parts[0];
|
||||
final int value;
|
||||
try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; }
|
||||
|
||||
final List<Long> ids = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?"
|
||||
+ " AND (deleted IS NULL OR deleted = false) LIMIT 1",
|
||||
Long.class, value, kind);
|
||||
if (ids.isEmpty()) continue;
|
||||
final Long denomId = ids.get(0);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_savings_txn_denomination
|
||||
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
|
||||
""",
|
||||
txnId, savingsAccountId, accountNo, clientId, denomId,
|
||||
value, count, BigDecimal.valueOf((long) value * count),
|
||||
cashierId, cashierUsername
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If a cash denomination breakdown is provided, its total must exactly equal
|
||||
* the deposit amount — prevents the recorded vault movement from drifting
|
||||
* away from the credited amount.
|
||||
*/
|
||||
private void validateDenominationTotal(final String denominationData, final BigDecimal amount) {
|
||||
if (denominationData == null || denominationData.isBlank()) return;
|
||||
final JsonObject denomMap;
|
||||
try {
|
||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||
} catch (Exception e) {
|
||||
throw apiError(400, "denominationData is not valid JSON");
|
||||
}
|
||||
if (denomMap == null || denomMap.size() == 0) return;
|
||||
|
||||
long total = 0;
|
||||
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
||||
final String[] parts = entry.getKey().split(":", 2); // "note:500" or "coin:1"
|
||||
if (parts.length != 2) continue;
|
||||
final int value;
|
||||
final int count;
|
||||
try {
|
||||
value = Integer.parseInt(parts[1]);
|
||||
count = entry.getValue().getAsInt();
|
||||
} catch (Exception e) {
|
||||
continue;
|
||||
}
|
||||
if (count > 0) total += (long) value * count;
|
||||
}
|
||||
if (total > 0 && BigDecimal.valueOf(total).compareTo(amount) != 0) {
|
||||
throw apiError(400,
|
||||
"Cash denomination total ₹" + total + " does not match deposit amount ₹" + amount
|
||||
+ ". Denomination breakdown must equal the deposit amount exactly.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checker authorization: only manager-level users may approve or reject.
|
||||
* Allowed: super users (ALL_FUNCTIONS), or any role whose name contains
|
||||
* "manager" (Bank Manager, Branch Manager …) or "head cashier".
|
||||
* Clerk and regular Cashier roles are makers — never checkers.
|
||||
*/
|
||||
private void validateCheckerRole(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return;
|
||||
}
|
||||
final boolean isChecker = user.getRoles().stream()
|
||||
.map(r -> r.getName() == null ? "" : r.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 deposit requests.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean JSON error the UI can display — avoids a 500 + stack trace from the
|
||||
* default exception mapper. Body carries the message under every key the
|
||||
* frontend error readers look for.
|
||||
*/
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
private String formatForFineract(final String isoDate) {
|
||||
try {
|
||||
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal parseBigDecimal(final String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
try {
|
||||
return new BigDecimal(raw);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildActivityLog(final String existingJson, final String action,
|
||||
final String username, final String note) {
|
||||
JsonArray log = new JsonArray();
|
||||
if (existingJson != null && !existingJson.isBlank()) {
|
||||
try {
|
||||
log = fromJsonHelper.parse(existingJson).getAsJsonArray();
|
||||
} catch (Exception e) {
|
||||
log = new JsonArray();
|
||||
}
|
||||
}
|
||||
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("timestamp", Instant.now().toString());
|
||||
entry.addProperty("action", action);
|
||||
entry.addProperty("user", username);
|
||||
if (note != null && !note.isBlank()) {
|
||||
entry.addProperty("note", note);
|
||||
}
|
||||
log.add(entry);
|
||||
return gson.toJson(log);
|
||||
}
|
||||
}
|
||||
+161
-37
@@ -54,6 +54,7 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
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;
|
||||
@@ -69,6 +70,7 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final VoucherService voucherService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
|
||||
@@ -97,13 +99,16 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw new IllegalArgumentException("Account number is required");
|
||||
throw apiError(400, "Account number is required");
|
||||
}
|
||||
if (savingsAccountId == null) {
|
||||
throw apiError(400, "Savings account id is required for a withdrawal request");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("Amount must be greater than zero");
|
||||
throw apiError(400, "Amount must be greater than zero");
|
||||
}
|
||||
if (tokNo == null || tokNo.isBlank()) {
|
||||
throw new IllegalArgumentException("Token number (TOK. NO.) is required");
|
||||
throw apiError(400, "Token number (TOK. NO.) is required");
|
||||
}
|
||||
validateTokenUniqueToday(tokNo, null);
|
||||
|
||||
@@ -123,11 +128,15 @@ 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())
|
||||
);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
||||
// Globally unique voucher number from the central m_voucher register
|
||||
final String voucherNo = voucherService.createVoucher(
|
||||
VoucherService.TYPE_WITHDRAWAL, VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, newId, amount, currentUser);
|
||||
jdbcTemplate.update("UPDATE m_clerk_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo));
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/withdrawal-requests ── Manager sees all requests ──────────
|
||||
@@ -166,7 +175,7 @@ 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"
|
||||
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
|
||||
+ " FROM m_clerk_withdrawal_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
@@ -220,7 +229,7 @@ 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
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
FROM m_clerk_withdrawal_request
|
||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray()
|
||||
@@ -244,7 +253,7 @@ 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
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
@@ -263,7 +272,7 @@ 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
|
||||
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
|
||||
FROM m_clerk_withdrawal_request
|
||||
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
|
||||
ORDER BY submitted_at DESC LIMIT 1
|
||||
@@ -288,9 +297,44 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
}
|
||||
|
||||
private WebApplicationException tokenError(final int httpStatus, final String message) {
|
||||
final String body = gson.toJson(Map.of("message", message, "httpStatus", httpStatus));
|
||||
return apiError(httpStatus, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checker authorization: only manager-level users may approve or reject.
|
||||
* Allowed: super users (ALL_FUNCTIONS), or any role whose name contains
|
||||
* "manager" (Bank Manager, Branch Manager …) or "head cashier".
|
||||
* Clerk and regular Cashier roles are makers — never checkers.
|
||||
*/
|
||||
private void validateCheckerRole(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return;
|
||||
}
|
||||
final boolean isChecker = user.getRoles().stream()
|
||||
.map(r -> r.getName() == null ? "" : r.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 withdrawal requests.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean JSON error the UI can display — avoids a 500 + stack trace from the
|
||||
* default exception mapper. Body carries the message under every key the
|
||||
* frontend error readers look for.
|
||||
*/
|
||||
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(body).type(MediaType.APPLICATION_JSON).build()
|
||||
Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,7 +350,12 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
if (!"REJECTED".equals(record.get("status"))) {
|
||||
throw new IllegalStateException("Only REJECTED requests can be edited. Current status: " + record.get("status"));
|
||||
throw apiError(409, "Only REJECTED requests can be edited. Current status: " + record.get("status"));
|
||||
}
|
||||
// Only the clerk who created the request may resubmit it
|
||||
final Long ownerId = ((Number) record.get("clerk_id")).longValue();
|
||||
if (!ownerId.equals(currentUser.getId())) {
|
||||
throw apiError(403, "Only the clerk who submitted this withdrawal request can edit it.");
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
@@ -321,10 +370,10 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("Amount must be greater than zero");
|
||||
throw apiError(400, "Amount must be greater than zero");
|
||||
}
|
||||
if (tokNo == null || tokNo.isBlank()) {
|
||||
throw new IllegalArgumentException("Token number (TOK. NO.) is required");
|
||||
throw apiError(400, "Token number (TOK. NO.) is required");
|
||||
}
|
||||
validateTokenUniqueToday(tokNo, id);
|
||||
|
||||
@@ -348,7 +397,17 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "PENDING"));
|
||||
// Keep the central voucher register in sync; backfill voucher for old records
|
||||
String voucherNo = (String) record.get("voucher_no");
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
voucherNo = voucherService.createVoucher(
|
||||
VoucherService.TYPE_WITHDRAWAL, VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, amount, currentUser);
|
||||
jdbcTemplate.update("UPDATE m_clerk_withdrawal_request SET voucher_no = ? WHERE id = ?", voucherNo, id);
|
||||
} else {
|
||||
voucherService.updateAmount(VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, amount);
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "PENDING", "voucherNo", voucherNo));
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/withdrawal-requests/{id}/approve ── Manager approves ──────
|
||||
@@ -360,10 +419,17 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
@Transactional
|
||||
public String approve(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw new IllegalStateException("Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||
}
|
||||
// Maker ≠ checker: a manager must not approve their own withdrawal request
|
||||
final Long makerId = ((Number) record.get("clerk_id")).longValue();
|
||||
if (makerId.equals(currentUser.getId())) {
|
||||
throw apiError(403,
|
||||
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
|
||||
}
|
||||
|
||||
final Long savingsAccountId = record.get("savings_account_id") != null
|
||||
@@ -379,19 +445,30 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
txnId = executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||
}
|
||||
|
||||
// Link voucher to the created savings txn and stamp voucher_no on it
|
||||
if (txnId != null) {
|
||||
voucherService.linkSavingsTransaction(
|
||||
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||
);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
// WHERE status = 'PENDING' guards against two managers approving at once;
|
||||
// @Transactional rolls back the savings withdrawal if we lose the race.
|
||||
final int approvedRows = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_withdrawal_request SET
|
||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
txn_id = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
txnId, updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
if (approvedRows == 0) {
|
||||
throw apiError(409, "This withdrawal request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
@@ -409,32 +486,36 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
@Transactional
|
||||
public String reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
validateCheckerRole(currentUser);
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"PENDING".equals(record.get("status"))) {
|
||||
throw new IllegalStateException("Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("Rejection reason is required");
|
||||
throw apiError(400, "Rejection reason is required");
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason
|
||||
);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
final int rejectedRows = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_withdrawal_request SET
|
||||
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
rejection_reason = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
reason, updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
if (rejectedRows == 0) {
|
||||
throw apiError(409, "This withdrawal request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||
}
|
||||
@@ -451,8 +532,7 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final Map<?, ?> record = loadRecord(id);
|
||||
|
||||
if (!"APPROVED".equals(record.get("status"))) {
|
||||
throw new IllegalStateException(
|
||||
"Only APPROVED requests can be disbursed. Current status: " + record.get("status"));
|
||||
throw apiError(409, "Only APPROVED requests can be disbursed. Current status: " + record.get("status"));
|
||||
}
|
||||
|
||||
String denominationData = null;
|
||||
@@ -465,19 +545,28 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
|
||||
// transactionAmount is mandatory — must always be provided
|
||||
if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"transactionAmount is required and must be greater than zero for disbursement."
|
||||
);
|
||||
throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement.");
|
||||
}
|
||||
|
||||
// Amount must exactly match the approved withdrawal — no tolerance for manipulation
|
||||
final BigDecimal approvedAmount = new BigDecimal(record.get("amount").toString());
|
||||
if (approvedAmount.compareTo(transactionAmount) != 0) {
|
||||
throw new IllegalArgumentException(
|
||||
throw apiError(400,
|
||||
"Transaction amount ₹" + transactionAmount
|
||||
+ " does not match approved withdrawal amount ₹" + approvedAmount
|
||||
+ ". Cash denomination total must equal the approved amount exactly."
|
||||
);
|
||||
+ ". Cash denomination total must equal the approved amount exactly.");
|
||||
}
|
||||
|
||||
// Denomination breakdown, when provided, must total exactly the disbursed amount
|
||||
final long denomTotal = denominationTotal(denominationData);
|
||||
if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) {
|
||||
throw apiError(400,
|
||||
"Cash denomination total ₹" + denomTotal + " does not match disbursement amount ₹"
|
||||
+ transactionAmount + ". Notes/coins must equal the approved amount exactly.");
|
||||
}
|
||||
// Cash payments must include the breakdown so the cashier vault position stays accurate
|
||||
if (denomTotal == 0 && isCashPaymentType(record.get("payment_type_id"))) {
|
||||
throw apiError(400, "Cash disbursement requires the denomination breakdown (notes/coins).");
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
@@ -496,9 +585,7 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
);
|
||||
|
||||
if (updated == 0) {
|
||||
throw new IllegalStateException(
|
||||
"This withdrawal has already been disbursed by another cashier. Double disbursement prevented."
|
||||
);
|
||||
throw apiError(409, "This withdrawal has already been disbursed by another cashier. Double disbursement prevented.");
|
||||
}
|
||||
|
||||
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
|
||||
@@ -506,7 +593,44 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
saveDisbursementDenomination(denominationData, record, currentUser);
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
|
||||
final Object voucherNoObj = record.get("voucher_no");
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED",
|
||||
"voucherNo", voucherNoObj != null ? voucherNoObj.toString() : ""));
|
||||
}
|
||||
|
||||
/** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */
|
||||
private long denominationTotal(final String denominationData) {
|
||||
if (denominationData == null || denominationData.isBlank()) return 0;
|
||||
final JsonObject denomMap;
|
||||
try {
|
||||
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||
} catch (Exception e) {
|
||||
throw apiError(400, "denominationData is not valid JSON");
|
||||
}
|
||||
if (denomMap == null) return 0;
|
||||
long total = 0;
|
||||
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
||||
final String[] parts = entry.getKey().split(":", 2);
|
||||
if (parts.length != 2) continue;
|
||||
final int value;
|
||||
final int count;
|
||||
try {
|
||||
value = Integer.parseInt(parts[1]);
|
||||
count = entry.getValue().getAsInt();
|
||||
} catch (Exception e) {
|
||||
continue;
|
||||
}
|
||||
if (count > 0) total += (long) value * count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private boolean isCashPaymentType(final Object paymentTypeId) {
|
||||
if (paymentTypeId == null) return false;
|
||||
final List<Boolean> rows = jdbcTemplate.queryForList(
|
||||
"SELECT is_cash_payment FROM m_payment_type WHERE id = ?",
|
||||
Boolean.class, ((Number) paymentTypeId).longValue());
|
||||
return !rows.isEmpty() && Boolean.TRUE.equals(rows.get(0));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,7 +697,7 @@ 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
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
@@ -646,7 +770,7 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||
).orElse(0L);
|
||||
if (count > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
throw apiError(400,
|
||||
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Central voucher register (m_voucher). Every maker-checker cash transaction
|
||||
* (clerk withdrawal request, cashier deposit request) gets a globally unique
|
||||
* voucher number from this single sequence, so a withdrawal and a deposit can
|
||||
* never share the same voucher number. The voucher is also stamped onto the
|
||||
* resulting savings transaction (m_savings_account_transaction.voucher_no)
|
||||
* once the manager approves and the transaction is created.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VoucherService {
|
||||
|
||||
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
|
||||
public static final String TYPE_DEPOSIT = "DEPOSIT";
|
||||
public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST";
|
||||
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Creates a voucher row and returns the formatted voucher number ("000001").
|
||||
* The number is the zero-padded primary key, so uniqueness is guaranteed by
|
||||
* the database sequence — no counter table, no race conditions.
|
||||
*/
|
||||
public String createVoucher(final String voucherType, final String referenceType,
|
||||
final Long referenceId, final BigDecimal amount, final AppUser user) {
|
||||
|
||||
final Long voucherId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_voucher
|
||||
(voucher_type, reference_type, reference_id, amount, created_by, created_by_username, created_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
voucherType, referenceType, referenceId, amount,
|
||||
user.getId(), user.getUsername(), Timestamp.from(Instant.now())
|
||||
);
|
||||
|
||||
final String voucherNo = String.format("%06d", voucherId);
|
||||
jdbcTemplate.update("UPDATE m_voucher SET voucher_no = ? WHERE id = ?", voucherNo, voucherId);
|
||||
return voucherNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
public String findVoucherNo(final String referenceType, final Long referenceId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT voucher_no FROM m_voucher WHERE reference_type = ? AND reference_id = ?"
|
||||
+ " ORDER BY id DESC LIMIT 1",
|
||||
referenceType, referenceId);
|
||||
return rows.isEmpty() ? null : (String) rows.get(0).get("voucher_no");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at manager approval, after the savings transaction is created:
|
||||
* links the savings txn to the voucher and stamps voucher_no onto
|
||||
* m_savings_account_transaction so statements and audits can show it.
|
||||
*/
|
||||
public void linkSavingsTransaction(final String referenceType, final Long referenceId,
|
||||
final Long savingsTxnId) {
|
||||
if (savingsTxnId == null) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, voucher_no FROM m_voucher WHERE reference_type = ? AND reference_id = ?"
|
||||
+ " ORDER BY id DESC LIMIT 1",
|
||||
referenceType, referenceId);
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final Long voucherId = ((Number) rows.get(0).get("id")).longValue();
|
||||
final String voucherNo = (String) rows.get(0).get("voucher_no");
|
||||
|
||||
jdbcTemplate.update("UPDATE m_voucher SET savings_txn_id = ? WHERE id = ?", savingsTxnId, voucherId);
|
||||
if (voucherNo != null) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_savings_account_transaction SET voucher_no = ? WHERE id = ?",
|
||||
voucherNo, savingsTxnId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the voucher amount in sync when a rejected request is edited and resubmitted. */
|
||||
public void updateAmount(final String referenceType, final Long referenceId, final BigDecimal amount) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_voucher SET amount = ? WHERE reference_type = ? AND reference_id = ?",
|
||||
amount, referenceType, referenceId);
|
||||
}
|
||||
}
|
||||
@@ -354,4 +354,8 @@
|
||||
<include file="parts/0324_add_daily_token_counter.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0325_add_disbursement_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0326_drop_daily_token_counter.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0327_add_voucher_no.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0328_add_voucher_no_to_savings_txn.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0329_create_voucher.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0330_create_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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="0327_add_voucher_no" author="CBS">
|
||||
<addColumn tableName="m_clerk_withdrawal_request">
|
||||
<column name="voucher_no" type="VARCHAR(6)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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="0328_add_voucher_no_to_savings_txn" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_savings_account_transaction" columnName="voucher_no"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_savings_account_transaction">
|
||||
<column name="voucher_no" type="VARCHAR(6)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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="0329_create_voucher" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_voucher"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_voucher">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="voucher_no" type="VARCHAR(6)">
|
||||
<constraints unique="true" uniqueConstraintName="uq_voucher_no"/>
|
||||
</column>
|
||||
<column name="voucher_type" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reference_type" type="VARCHAR(40)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reference_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="savings_txn_id" type="BIGINT"/>
|
||||
<column name="amount" type="DECIMAL(19,6)"/>
|
||||
<column name="created_by" type="BIGINT"/>
|
||||
<column name="created_by_username" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_voucher" indexName="idx_voucher_reference">
|
||||
<column name="reference_type"/>
|
||||
<column name="reference_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_voucher" indexName="idx_voucher_savings_txn">
|
||||
<column name="savings_txn_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_voucher" indexName="idx_voucher_created_at">
|
||||
<column name="created_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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="0330_create_cashier_deposit_request" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_cashier_deposit_request"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_cashier_deposit_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="voucher_no" type="VARCHAR(6)"/>
|
||||
<column name="account_no" type="VARCHAR(40)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="account_type" type="VARCHAR(20)" defaultValue="SAVINGS"/>
|
||||
<column name="savings_account_id" type="BIGINT"/>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="client_name" type="VARCHAR(200)"/>
|
||||
<column name="amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="payment_type_id" type="BIGINT"/>
|
||||
<column name="payment_type_name" type="VARCHAR(100)"/>
|
||||
<column name="description" type="VARCHAR(500)"/>
|
||||
<column name="cheque_no" type="VARCHAR(50)"/>
|
||||
<column name="bank_name" type="VARCHAR(100)"/>
|
||||
<column name="denomination_data" type="TEXT"/>
|
||||
<column name="txn_date" type="DATE"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="cashier_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="cashier_name" type="VARCHAR(200)"/>
|
||||
<column name="cashier_username" type="VARCHAR(100)"/>
|
||||
<column name="manager_id" type="BIGINT"/>
|
||||
<column name="manager_name" type="VARCHAR(200)"/>
|
||||
<column name="manager_username" type="VARCHAR(100)"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
<column name="txn_id" type="VARCHAR(40)"/>
|
||||
<column name="activity_log" type="TEXT"/>
|
||||
<column name="submitted_at" type="TIMESTAMP"/>
|
||||
<column name="actioned_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cashier_deposit_request" indexName="idx_cdr_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_cashier_deposit_request" indexName="idx_cdr_cashier">
|
||||
<column name="cashier_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_cashier_deposit_request" indexName="idx_cdr_submitted_at">
|
||||
<column name="submitted_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user