feat(clerk-withdrawal): add maker-checker withdrawal disbursement API

- ClerkWithdrawalRequestApiResource: full CRUD for withdrawal requests
- Endpoints: submit, list, myRequests, getById, getByToken, approve, reject, disburse, update
- Token uniqueness validated per day (validateTokenUniqueToday)
- Cashier disburse: amount match, race condition guard (WHERE status=APPROVED)
- Disbursement denomination saved to m_savings_txn_denomination for vault position tracking
- Liquibase migrations: 0321-0326 (create table, tok_no, manager fields, disbursement fields, drop unused counter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-11 16:00:57 +05:30
co-authored by Claude Sonnet 4.6
parent db94056144
commit 8d5b4d1ce9
8 changed files with 881 additions and 0 deletions
@@ -0,0 +1,675 @@
/**
* 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.clerkwithdrawal.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.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Path("/v1/clerk/withdrawal-requests")
@Component
@Tag(name = "Clerk Withdrawal Requests", description = "Maker-checker withdrawal request workflow for clerk and bank manager.")
@RequiredArgsConstructor
public class ClerkWithdrawalRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new 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 tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
if (accountNo == null || accountNo.isBlank()) {
throw new IllegalArgumentException("Account number is required");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be greater than zero");
}
if (tokNo == null || tokNo.isBlank()) {
throw new IllegalArgumentException("Token number (TOK. NO.) is required");
}
validateTokenUniqueToday(tokNo, null);
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_clerk_withdrawal_request
(account_no, account_type, savings_account_id, client_id, client_name, amount,
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
accountNo, accountType, savingsAccountId, clientId, clientName, amount,
paymentTypeId, paymentTypeName, description, chequeNo, bankName, tokNo,
txnDate != null ? Date.valueOf(txnDate) : null,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog,
Timestamp.from(Instant.now())
);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
}
// ── GET /v1/clerk/withdrawal-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_clerk_withdrawal_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, account_no, account_type, savings_account_id, client_id, client_name,"
+ " amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,"
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
+ " denomination_data, activity_log, submitted_at, actioned_at,"
+ " cashier_id, disbursed_by, disbursed_at"
+ " FROM m_clerk_withdrawal_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/clerk/withdrawal-requests/my ── Clerk'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 clerk_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_clerk_withdrawal_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, account_no, account_type, client_name, amount,
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
FROM m_clerk_withdrawal_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/clerk/withdrawal-requests/{id} ── Full detail ───────────────────
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String getById(@PathParam("id") final Long id) {
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, cheque_no, bank_name, tok_no,
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
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
throw new NotFoundException("Withdrawal request not found: " + id);
}
return gson.toJson(rows.get(0));
}
// ── GET /v1/clerk/withdrawal-requests/by-token/{token} ── Cashier token lookup ──
@GET
@Path("/by-token/{token}")
@Produces(MediaType.APPLICATION_JSON)
public String getByToken(@PathParam("token") final String token) {
// Query without status filter to give specific error per status
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
FROM m_clerk_withdrawal_request
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
ORDER BY submitted_at DESC LIMIT 1
""", token);
if (rows.isEmpty()) {
throw tokenError(404, "Token '" + token + "' not found for today. Please check the token number.");
}
final String status = String.valueOf(rows.get(0).get("status"));
return switch (status) {
case "APPROVED" -> gson.toJson(rows.get(0));
case "PENDING" -> throw tokenError(422,
"Token '" + token + "' is still PENDING — manager has not approved this request yet.");
case "DISBURSED" -> throw tokenError(422,
"Token '" + token + "' has already been disbursed. Cash was handed over by another cashier.");
case "REJECTED" -> throw tokenError(422,
"Token '" + token + "' was REJECTED by the manager. This withdrawal cannot be processed.");
default -> throw tokenError(422,
"Token '" + token + "' has unexpected status: " + status);
};
}
private WebApplicationException tokenError(final int httpStatus, final String message) {
final String body = gson.toJson(Map.of("message", message, "httpStatus", httpStatus));
return new WebApplicationException(
Response.status(httpStatus).entity(body).type(MediaType.APPLICATION_JSON).build()
);
}
// ── PUT /v1/clerk/withdrawal-requests/{id} ── Clerk 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 new IllegalStateException("Only REJECTED requests can be edited. Current status: " + record.get("status"));
}
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 tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be greater than zero");
}
if (tokNo == null || tokNo.isBlank()) {
throw new IllegalArgumentException("Token number (TOK. NO.) is required");
}
validateTokenUniqueToday(tokNo, id);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "RESUBMITTED", currentUser.getUsername(), null
);
jdbcTemplate.update("""
UPDATE m_clerk_withdrawal_request SET
amount = ?, payment_type_id = ?, payment_type_name = ?,
description = ?, cheque_no = ?, bank_name = ?, tok_no = ?, 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, tokNo,
txnDate != null ? Date.valueOf(txnDate) : null,
denominationData,
updatedLog, Timestamp.from(Instant.now()), id
);
return gson.toJson(Map.of("resourceId", id, "status", "PENDING"));
}
// ── POST /v1/clerk/withdrawal-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();
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"));
}
final Long savingsAccountId = record.get("savings_account_id") != null
? ((Number) record.get("savings_account_id")).longValue() : null;
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");
String txnId = null;
if (savingsAccountId != null) {
txnId = executeSavingsWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description);
}
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
);
jdbcTemplate.update("""
UPDATE m_clerk_withdrawal_request SET
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
txn_id = ?, activity_log = ?, actioned_at = ?
WHERE id = ?
""",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
txnId, updatedLog, Timestamp.from(Instant.now()), id
);
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/clerk/withdrawal-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();
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"));
}
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");
}
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "REJECTED", currentUser.getUsername(), reason
);
jdbcTemplate.update("""
UPDATE m_clerk_withdrawal_request SET
status = 'REJECTED', manager_id = ?, manager_name = ?, manager_username = ?,
rejection_reason = ?, activity_log = ?, actioned_at = ?
WHERE id = ?
""",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
reason, updatedLog, Timestamp.from(Instant.now()), id
);
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
}
// ── POST /v1/clerk/withdrawal-requests/{id}/disburse ── Cashier confirms cash disbursement ──
@POST
@Path("/{id}/disburse")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String disburse(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
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"));
}
String denominationData = null;
BigDecimal transactionAmount = null;
if (body != null && !body.isBlank()) {
final JsonElement root = fromJsonHelper.parse(body);
denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
transactionAmount = parseBigDecimal(fromJsonHelper.extractStringNamed("transactionAmount", root));
}
// 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."
);
}
// 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(
"Transaction amount ₹" + transactionAmount
+ " does not match approved withdrawal amount ₹" + approvedAmount
+ ". Cash denomination total must equal the approved amount exactly."
);
}
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "DISBURSED", currentUser.getUsername(), null
);
// WHERE status = 'APPROVED' prevents race condition — only one cashier can win
final int updated = jdbcTemplate.update("""
UPDATE m_clerk_withdrawal_request SET
status = 'DISBURSED', cashier_id = ?, disbursed_by = ?,
disbursement_denomination_data = ?, activity_log = ?, disbursed_at = ?
WHERE id = ? AND status = 'APPROVED'
""",
currentUser.getId(), currentUser.getDisplayName(),
denominationData, updatedLog, Timestamp.from(Instant.now()), id
);
if (updated == 0) {
throw new IllegalStateException(
"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
if (denominationData != null && !denominationData.isBlank()) {
saveDisbursementDenomination(denominationData, record, currentUser);
}
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
}
/**
* Saves the cashier's denomination breakdown for a disbursement into m_savings_txn_denomination.
* denominationData format: {"note:500": 2, "coin:1": 10} (kind:value → count)
* This ensures the cashier's vault position report reflects the cash given out.
*/
private void saveDisbursementDenomination(final String denominationData,
final Map<?, ?> record, final AppUser cashier) {
final com.google.gson.JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, com.google.gson.JsonObject.class);
} catch (Exception e) {
return; // malformed JSON — skip silently
}
if (denomMap == null || denomMap.size() == 0) return;
final Object txnIdObj = record.get("txn_id");
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
if (txnId == null) return; // no savings txn linked — cannot record denomination
final 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;
for (final Map.Entry<String, com.google.gson.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; }
// Resolve denomination_id from master
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, 'WITHDRAWAL', ?, ?)
""",
txnId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
cashier.getId(), cashier.getUsername()
);
}
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,
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
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
throw new NotFoundException("Withdrawal request not found: " + id);
}
return rows.get(0);
}
private String executeSavingsWithdrawal(
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()
.savingsAccountWithdrawal(savingsAccountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
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;
}
}
/**
* Ensures the token number is unique for today's date among non-rejected requests.
* {@code excludeId} is set during resubmit to skip the record being edited.
*/
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
final StringBuilder sql = new StringBuilder(
"SELECT COUNT(*) FROM m_clerk_withdrawal_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
final List<Object> params = new ArrayList<>();
params.add(tokNo);
if (excludeId != null) {
sql.append(" AND id <> ?");
params.add(excludeId);
}
final long count = Optional.ofNullable(
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
).orElse(0L);
if (count > 0) {
throw new IllegalArgumentException(
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
}
}
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);
}
}
@@ -348,4 +348,10 @@
<include file="parts/0318_allow_nullable_savings_account_denomination.xml" relativeToChangelogFile="true"/>
<include file="parts/0319_allow_nullable_txn_id_denomination.xml" relativeToChangelogFile="true"/>
<include file="parts/0320_add_payment_type_to_denomination.xml" relativeToChangelogFile="true"/>
<include file="parts/0321_create_clerk_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0322_add_tok_no_to_clerk_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0323_add_manager_name_to_clerk_withdrawal_request.xml" relativeToChangelogFile="true"/>
<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"/>
</databaseChangeLog>
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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="0321_create_clerk_withdrawal_request" author="CBS">
<createTable tableName="m_clerk_withdrawal_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="account_no" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="account_type" type="VARCHAR(20)" defaultValue="SAVINGS">
<constraints nullable="false"/>
</column>
<column name="savings_account_id" type="BIGINT"/>
<column name="client_id" type="BIGINT"/>
<column name="client_name" type="VARCHAR(255)"/>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="payment_type_id" type="INT"/>
<column name="payment_type_name" type="VARCHAR(100)"/>
<column name="description" type="TEXT"/>
<column name="cheque_no" type="VARCHAR(50)"/>
<column name="bank_name" type="VARCHAR(100)"/>
<column name="txn_date" type="DATE"/>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="clerk_id" type="BIGINT"/>
<column name="clerk_name" type="VARCHAR(255)"/>
<column name="clerk_username" type="VARCHAR(100)"/>
<column name="manager_id" type="BIGINT"/>
<column name="manager_username" type="VARCHAR(100)"/>
<column name="rejection_reason" type="TEXT"/>
<column name="txn_id" type="VARCHAR(100)"/>
<column name="denomination_data" type="TEXT"/>
<column name="activity_log" type="TEXT"/>
<column name="submitted_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="actioned_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_clerk_id">
<column name="clerk_id"/>
</createIndex>
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_submitted_at">
<column name="submitted_at"/>
</createIndex>
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_savings_account_id">
<column name="savings_account_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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="0322_add_tok_no_to_clerk_withdrawal_request" author="CBS">
<addColumn tableName="m_clerk_withdrawal_request">
<column name="tok_no" type="VARCHAR(50)"/>
</addColumn>
<createIndex tableName="m_clerk_withdrawal_request" indexName="idx_cwr_tok_no">
<column name="tok_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<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="0323_add_manager_name_to_clerk_withdrawal_request" author="CBS">
<addColumn tableName="m_clerk_withdrawal_request">
<column name="manager_name" type="VARCHAR(150)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,21 @@
<?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="0324_add_daily_token_counter" author="CBS">
<createTable tableName="m_daily_token_counter">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="counter_date" type="DATE">
<constraints nullable="false" unique="true"/>
</column>
<column name="last_token" type="INTEGER" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
</createTable>
</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="0325_add_disbursement_fields" author="CBS">
<addColumn tableName="m_clerk_withdrawal_request">
<column name="cashier_id" type="BIGINT"/>
<column name="disbursed_by" type="VARCHAR(150)"/>
<column name="disbursed_at" type="TIMESTAMP"/>
<column name="disbursement_denomination_data" type="TEXT"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,14 @@
<?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="0326_drop_daily_token_counter" author="CBS">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_daily_token_counter"/>
</preConditions>
<dropTable tableName="m_daily_token_counter"/>
</changeSet>
</databaseChangeLog>