diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java new file mode 100644 index 000000000..7a0b27413 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -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 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 pageParams = new ArrayList<>(baseParams); + pageParams.add(limit); + pageParams.add(offset); + + final List> 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 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 pageParams = new ArrayList<>(params); + pageParams.add(limit); + pageParams.add(offset); + + final List> 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> 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 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 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 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); + } +} 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 index 7e81fa5ea..bf97e5a50 100644 --- 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 @@ -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> 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 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 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."); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java new file mode 100644 index 000000000..0af847350 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/voucher/service/VoucherService.java @@ -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> 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> 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); + } +} 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 531a4625b..118f3c35f 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 @@ -354,4 +354,8 @@ + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0327_add_voucher_no.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0327_add_voucher_no.xml new file mode 100644 index 000000000..f2ff45994 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0327_add_voucher_no.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0328_add_voucher_no_to_savings_txn.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0328_add_voucher_no_to_savings_txn.xml new file mode 100644 index 000000000..7c6f8752b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0328_add_voucher_no_to_savings_txn.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0329_create_voucher.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0329_create_voucher.xml new file mode 100644 index 000000000..0644d88ae --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0329_create_voucher.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0330_create_cashier_deposit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0330_create_cashier_deposit_request.xml new file mode 100644 index 000000000..a72c953ea --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0330_create_cashier_deposit_request.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +