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:
Jaanu423
2026-06-12 10:53:47 +05:30
co-authored by Claude Sonnet 4.6
parent 8d5b4d1ce9
commit 33ea10689f
8 changed files with 1056 additions and 37 deletions
@@ -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);
}
}
@@ -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.");
}
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>