From 8d5b4d1ce954a248a4752c460bc03505670a801d Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Thu, 11 Jun 2026 16:00:57 +0530 Subject: [PATCH] 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 --- .../ClerkWithdrawalRequestApiResource.java | 675 ++++++++++++++++++ .../db/changelog/tenant/changelog-tenant.xml | 6 + .../0321_create_clerk_withdrawal_request.xml | 81 +++ ...add_tok_no_to_clerk_withdrawal_request.xml | 36 + ...nager_name_to_clerk_withdrawal_request.xml | 32 + .../parts/0324_add_daily_token_counter.xml | 21 + .../parts/0325_add_disbursement_fields.xml | 16 + .../parts/0326_drop_daily_token_counter.xml | 14 + 8 files changed, 881 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0321_create_clerk_withdrawal_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0322_add_tok_no_to_clerk_withdrawal_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0323_add_manager_name_to_clerk_withdrawal_request.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0324_add_daily_token_counter.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0325_add_disbursement_fields.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0326_drop_daily_token_counter.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java new file mode 100644 index 000000000..7e81fa5ea --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -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 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 pageParams = new ArrayList<>(baseParams); + pageParams.add(limit); + pageParams.add(offset); + + final List> 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 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 pageParams = new ArrayList<>(params); + pageParams.add(limit); + pageParams.add(offset); + + final List> 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> 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> 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 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 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> 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 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); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 2f2ba7c07..531a4625b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -348,4 +348,10 @@ + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0321_create_clerk_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0321_create_clerk_withdrawal_request.xml new file mode 100644 index 000000000..4d75d489b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0321_create_clerk_withdrawal_request.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0322_add_tok_no_to_clerk_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0322_add_tok_no_to_clerk_withdrawal_request.xml new file mode 100644 index 000000000..6f81977a7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0322_add_tok_no_to_clerk_withdrawal_request.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0323_add_manager_name_to_clerk_withdrawal_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0323_add_manager_name_to_clerk_withdrawal_request.xml new file mode 100644 index 000000000..2f67c09c5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0323_add_manager_name_to_clerk_withdrawal_request.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0324_add_daily_token_counter.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0324_add_daily_token_counter.xml new file mode 100644 index 000000000..0e490bdf3 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0324_add_daily_token_counter.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0325_add_disbursement_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0325_add_disbursement_fields.xml new file mode 100644 index 000000000..02e3c7303 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0325_add_disbursement_fields.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0326_drop_daily_token_counter.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0326_drop_daily_token_counter.xml new file mode 100644 index 000000000..00677b73d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0326_drop_daily_token_counter.xml @@ -0,0 +1,14 @@ + + + + + + + + + + +