diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java index 794cf15c6..15d6924a5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashreport/api/CashReportApiResource.java @@ -94,6 +94,34 @@ public class CashReportApiResource { WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE) """, this::mapDayBookRow, txnDate)); + // Account closures — a full-balance payout, so the debit side. Its own register rather than + // the withdrawal one: a closure carries no clerk-entered amount (the figure is whatever + // close() computes at approval) and the cash handed over is the ROUNDED + // cashier_disbursed_amount, which is what a cash book has to agree with, not the + // paisa-exact closing_amount posted to the member's ledger. + // + // Only CLOSED rows, unlike the registers above: a closure becomes a cash-book event when the + // cashier actually hands the notes over, and not before. A PENDING or APPROVED closure has + // taken nothing out of the drawer yet, and a REJECTED one never will — listing those would + // put rows with no cash behind them (a rejected closure has no amount at all) into the + // teller's day book. And the day they belong to is the day the cash left, so this filters on + // disbursed_at, falling back to closure_date for a nil-balance closure that had no cashier + // step to reach. + rows.addAll(jdbcTemplate.query(""" + SELECT r.voucher_no, 'DR' AS side, 'CLOSURE' AS kind, r.account_no, + 'SAVINGS' AS account_type, r.client_name AS party, + COALESCE(r.cashier_disbursed_amount, r.closing_amount) AS amount, + r.payment_type_name, r.description, r.status, + COALESCE(r.disbursed_by, r.clerk_name) AS handled_by, + r.manager_name AS approved_by, r.tok_no AS txn_id, + r.submitted_at, r.actioned_at, + """ + verifyCols + """ + FROM m_clerk_closure_request r + """ + verifyJoin + """ + WHERE r.status = 'CLOSED' + AND COALESCE(CAST(r.disbursed_at AS DATE), r.closure_date) = CAST(? AS DATE) + """, this::mapDayBookRow, txnDate)); + // Disbursement register: a collection pulls money into the GL (CR); a disbursement or a // bank transfer moves it out (DR) — matching how the voucher itself was numbered. rows.addAll(jdbcTemplate.query(""" @@ -128,13 +156,15 @@ public class CashReportApiResource { filtered.add(row); // Only approved money is real money: a pending or rejected voucher must not inflate the - // day's totals, even though it still belongs in the listing. The three source registers - // don't share one terminal status string: deposits and disbursements land on APPROVED, - // but a clerk withdrawal's own finished state is WITHDRAWN -- missing it here meant a - // completed withdrawal moved real money out yet never showed up in the DR total at all. + // day's totals, even though it still belongs in the listing. The source registers don't + // share one terminal status string: deposits and disbursements land on APPROVED, a clerk + // withdrawal's finished state is WITHDRAWN, and an account closure's is CLOSED -- missing + // any of them meant a completed transaction moved real money out yet never showed up in + // the DR total at all. A closure is one row that moves APPROVED -> CLOSED, so counting + // both states cannot double-count it; only 'CLOSED' is unique to that register. final String rowStatus = (String) row.get("status"); if ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) - || "WITHDRAWN".equalsIgnoreCase(rowStatus)) { + || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus)) { final BigDecimal amount = (BigDecimal) row.get("amount"); if (amount != null) { if ("CR".equals(row.get("side"))) { @@ -833,6 +863,12 @@ public class CashReportApiResource { + " (cwr.txn_id = CAST(std.txn_id AS VARCHAR) " + " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' " + " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id)) " + // An account closure also writes its hand-over as a 'WITHDRAWAL' denomination row + // (the column records which way cash moved), but its request lives in its own + // register -- without this join the closure's real voucher number is never found and + // the report falls back to showing the bare savings-transaction id instead. + + "LEFT JOIN m_clerk_closure_request ccr ON std.txn_type = 'WITHDRAWAL' AND " + + " ccr.txn_id = CAST(std.txn_id AS VARCHAR) " + "WHERE std.txn_date = CAST(? AS DATE) " + officerFilter + " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no "; @@ -853,7 +889,8 @@ public class CashReportApiResource { // 2. Paged data query final String dataSql = "SELECT " - + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + + " COALESCE(MAX(cdr.voucher_no), MAX(cwr.voucher_no), MAX(ccr.voucher_no), " + + " CAST(std.txn_id AS VARCHAR)) AS voucher_no, " + " std.account_no AS ac_no, " + " COALESCE(mc.display_name, std.cif_no, MAX(gla.name), '') AS ac_name, " + " MAX(std.txn_date) AS txn_date, " diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java new file mode 100644 index 000000000..4d3d6f201 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -0,0 +1,1039 @@ +/** + * 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.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.math.MathContext; +import java.math.RoundingMode; +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.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.voucher.service.VoucherLine; +import org.apache.fineract.portfolio.voucher.service.VoucherService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Maker-checker Cash-only savings account closure. Mirrors {@link ClerkWithdrawalRequestApiResource}'s + * shape: a clerk submits a request (account + token, no amount -- unknown until approval), a bank + * manager approves it (which runs Fineract's own {@code close} command -- pro-rata interest, TDS and + * the real GL withdrawal all post atomically at that moment, exactly as they would from the ordinary + * Close Savings Account screen), and a cashier hands over the physical cash against the token, + * recording the denomination breakdown. + * + * Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded to the + * nearest rupee for the physical hand-over; any few-paise difference is absorbed by the dedicated + * "Round Off Account" GL account via one small adjusting journal entry, never touching the member's own + * ledger (which stays exactly correct to the paisa). + */ +@Path("/v1/clerk/closure-requests") +@Component +@Tag(name = "Clerk Closure Requests", description = "Maker-checker Cash-only savings account closure workflow for clerk, bank manager and cashier.") +@RequiredArgsConstructor +public class ClerkClosureRequestApiResource { + + // Named LOG rather than @Slf4j: buildActivityLog uses a local "log" for the activity JsonArray, + // and a field of the same name reads as that variable at a glance. + private static final Logger LOG = LoggerFactory.getLogger(ClerkClosureRequestApiResource.class); + + private final JdbcTemplate jdbcTemplate; + private final FromJsonHelper fromJsonHelper; + private final PlatformSecurityContext context; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + // Used only by preview(), to compute the closing figure the way close() will actually compute it. + private final SavingsAccountAssembler savingsAccountAssembler; + private final ConfigurationDomainService configurationDomainService; + private final PlatformTransactionManager transactionManager; + private final VoucherService voucherService; + private final Gson gson = new GsonBuilder().serializeNulls().create(); + + // ── POST /v1/clerk/closure-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 Long savingsAccountId = fromJsonHelper.extractLongNamed("savingsAccountId", root); + final Long paymentTypeId = fromJsonHelper.extractLongNamed("paymentTypeId", root); + final String paymentTypeName = Optional.ofNullable(fromJsonHelper.extractStringNamed("paymentTypeName", root)).orElse("Cash"); + final String description = fromJsonHelper.extractStringNamed("description", root); + final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root); + + if (savingsAccountId == null) { + throw apiError(400, "Savings account id is required for a closure request"); + } + // Cash only for now -- Transfer/Cheque/NEFT/RTGS still go through the direct Close Savings + // Account screen, which has no physical cash to track and needs no cashier step. + if (!isCashPaymentType(paymentTypeId)) { + throw apiError(400, + "Only Cash closures are supported here for now. For Transfer/Cheque/NEFT/RTGS, use the " + + "Close Savings Account screen directly from the account's own page."); + } + if (tokNo == null || tokNo.isBlank()) { + throw apiError(400, "Token number (TOK. NO.) is required for a cash closure."); + } + validateTokenUniqueToday(tokNo); + + final Map account = loadSavingsAccountSummary(savingsAccountId); + if (account == null) { + throw apiError(404, "Savings account not found: " + savingsAccountId); + } + final Integer statusEnum = ((Number) account.get("status_enum")).intValue(); + if (statusEnum != 300) { + throw apiError(409, "Only an ACTIVE account can be closed. Current status: " + account.get("status_enum")); + } + final long openRequests = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_clerk_closure_request WHERE savings_account_id = ? AND status IN ('PENDING','APPROVED')", + Long.class, savingsAccountId) + ).orElse(0L); + if (openRequests > 0) { + throw apiError(409, "This account already has an open closure request (pending or awaiting cashier)."); + } + + final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); + + final Long newId = jdbcTemplate.queryForObject(""" + INSERT INTO m_clerk_closure_request + (savings_account_id, account_no, client_id, client_name, closure_date, + payment_type_id, payment_type_name, description, tok_no, + status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + RETURNING id + """, + Long.class, + savingsAccountId, account.get("account_no"), account.get("client_id"), account.get("client_name"), + Date.valueOf(DateUtils.getBusinessLocalDate()), + paymentTypeId, paymentTypeName, description, tokNo, + "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), + activityLog, Timestamp.from(Instant.now()) + ); + + return gson.toJson(Map.of("resourceId", newId, "status", "PENDING")); + } + + // ── GET /v1/clerk/closure-requests/preview/{savingsAccountId} ────────────── + + /** + * "What would this member actually receive" preview, so a clerk sees the real closing figure + * before submitting. + * + * This must reproduce what close() will pay out, to the paisa, and reading the stored + * {@code total_interest_earned_derived} does not: that column is only as fresh as whatever last + * touched the account, so it reads days stale. Nor does the CALCULATEINTEREST command, which is + * close but not identical -- it uses {@code MathContext(15)} and passes + * {@code postInterestOnDate = null}, whereas close() calls + * {@code postInterest(account, true, closedDate, false)}, which uses {@code MathContext(10)} and + * feeds {@code closedDate} in as an extra posting-period boundary + * (SavingsAccount#calculateInterestUsing adds it to postedAsOnDates). Splitting the final period + * changes how many per-period roundings happen, so the two can disagree by paise. + * + * So this runs the real thing as a dry run: assemble the account, invoke the same DOMAIN method + * close() ends up in with the same arguments, read the resulting balance -- which is exactly the + * figure close() withdraws (it takes {@code account.getSummary().getAccountBalance()} right + * after posting interest) -- and then roll the whole thing back. + * + * Why this is safe to run per keystroke: + * + */ + @GET + @Path("/preview/{savingsAccountId}") + @Produces(MediaType.APPLICATION_JSON) + public String preview(@PathParam("savingsAccountId") final Long savingsAccountId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.account_no, sa.status_enum, sa.deposit_type_enum, + COALESCE(c.display_name, g.display_name) AS client_name, + COALESCE(sa.account_balance_derived, 0) AS balance + FROM m_savings_account sa + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + WHERE sa.id = ? + """, savingsAccountId); + if (rows.isEmpty()) { + throw apiError(404, "Savings account not found: " + savingsAccountId); + } + final Map account = rows.get(0); + // Savings only (deposit_type_enum 100). An FD/RD assembles as a FixedDepositAccount / + // RecurringDepositAccount, whose interest and tax on closure run through + // postMaturityInterest / applyWithholdTaxForDepositAccounts instead -- the dry run below + // would quietly compute the wrong number for them. + final int depositType = ((Number) account.get("deposit_type_enum")).intValue(); + if (depositType != 100) { + throw apiError(400, "This closure flow supports savings accounts only (deposit type " + depositType + ")."); + } + + final BigDecimal storedBalance = new BigDecimal(account.get("balance").toString()); + // The CBS business date, not wall-clock: this is the date the closure will actually post on + // (executeClose sends it as closedOnDate) and the date close() itself calculates interest up + // to. Using LocalDate.now() here would silently disagree with approval around a day roll. + final LocalDate closureDate = DateUtils.getBusinessLocalDate(); + + // Read configuration first -- see the flush note in the javadoc. + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = configurationDomainService.retrieveFinancialYearBeginningMonth(); + final MathContext mc = new MathContext(10, MoneyHelper.getRoundingMode()); + + final TransactionTemplate dryRun = new TransactionTemplate(transactionManager); + dryRun.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + + BigDecimal[] figures = null; + try { + figures = dryRun.execute(status -> { + final SavingsAccount acct = savingsAccountAssembler.assembleFrom(savingsAccountId, false); + final BigDecimal before = zeroIfNull(acct.getSummary().getAccountBalance()); + // The exact call close() makes, via postInterest(account, true, closedDate, false). + acct.postInterest(mc, closureDate, false, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, closureDate, false, false); + final BigDecimal after = zeroIfNull(acct.getSummary().getAccountBalance()); + status.setRollbackOnly(); + return new BigDecimal[] { before, after }; + }); + } catch (final RuntimeException e) { + // Never cost the clerk the preview entirely -- fall through to the stored balance and + // flag that the interest figure could not be computed. + figures = null; + } + + final boolean computed = figures != null; + final BigDecimal balance = computed ? figures[0] : storedBalance; + final BigDecimal total = computed ? figures[1] : storedBalance; + final BigDecimal interest = total.subtract(balance); + + final JsonObject res = new JsonObject(); + res.addProperty("savingsAccountId", savingsAccountId); + res.addProperty("accountNo", String.valueOf(account.get("account_no"))); + res.addProperty("clientName", account.get("client_name") == null ? "" : String.valueOf(account.get("client_name"))); + res.addProperty("statusEnum", ((Number) account.get("status_enum")).intValue()); + res.addProperty("balance", balance); + res.addProperty("accruedInterest", interest); + res.addProperty("total", total); + // What a cashier could physically count out: no paisa coins circulate, so the hand-over is + // always whole rupees (the difference lands on the Round Off Account at approval). + res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); + res.addProperty("interestUpToDate", computed); + res.addProperty("asOfDate", closureDate.toString()); + return gson.toJson(res); + } + + private BigDecimal zeroIfNull(final BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + // ── GET /v1/clerk/closure-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, + @QueryParam("search") final String search) { + + 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)); + } + if (search != null && !search.isBlank()) { + where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR tok_no ILIKE ?)"); + final String like = "%" + search.trim() + "%"; + baseParams.add(like); + baseParams.add(like); + baseParams.add(like); + } + + final long total = Optional.ofNullable( + jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_closure_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, savings_account_id, account_no, client_id, client_name, closure_date," + + " payment_type_id, payment_type_name, description, tok_no, status," + + " clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username," + + " rejection_reason, txn_id, closing_amount, rounding_amount, cashier_disbursed_amount," + + " cashier_id, disbursed_by, disbursed_at, voucher_no, submitted_at, actioned_at" + + " FROM m_clerk_closure_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/closure-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_closure_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, savings_account_id, account_no, client_name, closure_date, + payment_type_name, description, tok_no, status, + clerk_name, manager_name, rejection_reason, txn_id, + closing_amount, rounding_amount, cashier_disbursed_amount, + voucher_no, submitted_at, actioned_at + FROM m_clerk_closure_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/closure-requests/{id} ── Full detail ────────────────────── + + @GET + @Path("/{id}") + @Produces(MediaType.APPLICATION_JSON) + public String getById(@PathParam("id") final Long id) { + return gson.toJson(loadRecord(id)); + } + + // ── GET /v1/clerk/closure-requests/by-token/{token} ── Cashier token lookup ── + + @GET + @Path("/by-token/{token}") + @Produces(MediaType.APPLICATION_JSON) + public String getByToken(@PathParam("token") final String token) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, savings_account_id, account_no, client_id, client_name, + payment_type_id, payment_type_name, tok_no, status, + clerk_id, clerk_name, manager_id, manager_name, + closing_amount, rounding_amount, cashier_disbursed_amount, + voucher_no, submitted_at, actioned_at + FROM m_clerk_closure_request + WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE + ORDER BY submitted_at DESC LIMIT 1 + """, token); + + if (rows.isEmpty()) { + throw apiError(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 apiError(422, + "Token '" + token + "' is still PENDING — manager has not approved this closure yet."); + case "CLOSED" -> throw apiError(422, + "Token '" + token + "' has already been closed. Cash was handed over by another cashier."); + case "REJECTED" -> throw apiError(422, + "Token '" + token + "' was REJECTED by the manager. This closure cannot be processed."); + default -> throw apiError(422, "Token '" + token + "' has unexpected status: " + status); + }; + } + + // ── POST /v1/clerk/closure-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")); + } + final Long makerId = ((Number) record.get("clerk_id")).longValue(); + if (makerId.equals(currentUser.getId())) { + throw apiError(403, + "Maker-checker: you cannot approve a closure request you submitted yourself. Ask another manager to review it."); + } + + final Long savingsAccountId = ((Number) record.get("savings_account_id")).longValue(); + final Map account = loadSavingsAccountSummary(savingsAccountId); + if (account == null) { + throw apiError(404, "Savings account not found: " + savingsAccountId); + } + final Integer statusEnum = ((Number) account.get("status_enum")).intValue(); + if (statusEnum != 300) { + throw apiError(409, "Account is no longer ACTIVE (status " + statusEnum + ") — cannot close."); + } + + // Fineract's own close command: pro-rata interest, TDS and the real GL withdrawal all post + // atomically here -- exactly as they would from the ordinary Close Savings Account screen. + executeClose(savingsAccountId); + + // Read back the withdrawal transaction close() just posted -- its CommandProcessingResult + // doesn't return the transaction id/amount directly. transaction_type_enum 2 = WITHDRAWAL. + final Map txn = jdbcTemplate.queryForList(""" + SELECT id, amount, office_id FROM m_savings_account_transaction + WHERE savings_account_id = ? AND transaction_type_enum = 2 AND is_reversed = false + ORDER BY id DESC LIMIT 1 + """, savingsAccountId).stream().findFirst().orElse(null); + + String txnId = null; + BigDecimal closingAmount = BigDecimal.ZERO; + BigDecimal roundingAmount = BigDecimal.ZERO; + BigDecimal cashierDisbursedAmount = BigDecimal.ZERO; + + if (txn != null) { + txnId = txn.get("id").toString(); + closingAmount = new BigDecimal(txn.get("amount").toString()); + cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); + roundingAmount = cashierDisbursedAmount.subtract(closingAmount); + + if (roundingAmount.compareTo(BigDecimal.ZERO) != 0) { + final Long officeId = ((Number) txn.get("office_id")).longValue(); + postRoundingAdjustment(officeId, roundingAmount, currentUser); + } + } + + // A closure with nothing to pay out (an already-empty account) has no cashier step to wait + // for -- the account is closed the moment this is approved. Leaving it APPROVED would park + // it in the cashier's token queue forever with zero cash to hand over. + final boolean awaitingCashier = cashierDisbursedAmount.compareTo(BigDecimal.ZERO) > 0; + final String newStatus = awaitingCashier ? "APPROVED" : "CLOSED"; + + // No voucher here on purpose: a voucher records cash actually crossing the counter, and at + // approval nothing has left the drawer yet -- the cashier still has to hand the notes over + // against the token. It is raised in disburse(), in the cashier's own name. A nil-balance + // closure therefore never gets one, which is right: no cash ever moved. + final String updatedLog = buildActivityLog( + (String) record.get("activity_log"), newStatus, currentUser.getUsername(), + awaitingCashier ? null : "Nil balance on closure — no cash hand-over required." + ); + + final int approvedRows = jdbcTemplate.update(""" + UPDATE m_clerk_closure_request SET + status = ?, manager_id = ?, manager_name = ?, manager_username = ?, + txn_id = ?, closing_amount = ?, rounding_amount = ?, cashier_disbursed_amount = ?, + activity_log = ?, actioned_at = ? + WHERE id = ? AND status = 'PENDING' + """, + newStatus, currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), + txnId, closingAmount, roundingAmount, cashierDisbursedAmount, + updatedLog, Timestamp.from(Instant.now()), id + ); + if (approvedRows == 0) { + throw apiError(409, "This closure request was already actioned by another manager."); + } + + final JsonObject res = new JsonObject(); + res.addProperty("resourceId", id); + res.addProperty("status", newStatus); + res.addProperty("closingAmount", closingAmount); + res.addProperty("roundingAmount", roundingAmount); + res.addProperty("cashierDisbursedAmount", cashierDisbursedAmount); + return gson.toJson(res); + } + + // ── POST /v1/clerk/closure-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 rejectedRows = jdbcTemplate.update(""" + UPDATE m_clerk_closure_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 (rejectedRows == 0) { + throw apiError(409, "This closure request was already actioned by another manager."); + } + + return gson.toJson(Map.of("resourceId", id, "status", "REJECTED")); + } + + // ── POST /v1/clerk/closure-requests/{id}/disburse ── Cashier hands over cash ─ + + @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 apiError(409, "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)); + } + + if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement."); + } + + // Cashier hands over the ROUNDED figure (whole rupees), not the paisa-exact closing_amount. + final BigDecimal approvedAmount = new BigDecimal(record.get("cashier_disbursed_amount").toString()); + if (approvedAmount.compareTo(transactionAmount) != 0) { + throw apiError(400, + "Transaction amount ₹" + transactionAmount + + " does not match the approved cash-to-hand-over amount ₹" + approvedAmount + + ". Cash denomination total must equal it exactly."); + } + + 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."); + } + if (denomTotal == 0) { + throw apiError(400, "Cash disbursement requires the denomination breakdown (notes/coins)."); + } + + final String updatedLog = buildActivityLog( + (String) record.get("activity_log"), "CLOSED", currentUser.getUsername(), null + ); + + final int updated = jdbcTemplate.update(""" + UPDATE m_clerk_closure_request SET + status = 'CLOSED', 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 apiError(409, "This closure has already been completed by another cashier. Double disbursement prevented."); + } + + if (denominationData != null && !denominationData.isBlank()) { + saveDisbursementDenomination(denominationData, record, currentUser); + } + + // The voucher belongs to this moment -- the cash has now physically crossed the counter -- + // and to this user, the cashier who counted it out. + final Object txnIdObj = record.get("txn_id"); + final String voucherNo = raiseClosureVoucher(id, + ((Number) record.get("savings_account_id")).longValue(), + txnIdObj == null ? null : Long.valueOf(txnIdObj.toString()), + record, new BigDecimal(record.get("closing_amount").toString()), + record.get("rounding_amount") == null ? BigDecimal.ZERO + : new BigDecimal(record.get("rounding_amount").toString()), + approvedAmount, currentUser); + if (voucherNo != null) { + jdbcTemplate.update("UPDATE m_clerk_closure_request SET voucher_no = ? WHERE id = ?", voucherNo, id); + } + + return gson.toJson(Map.of("resourceId", id, "status", "CLOSED", + "voucherNo", voucherNo != null ? voucherNo : "")); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private void executeClose(final Long savingsAccountId) { + // Business date, matching what preview() computed the figure against. + final String today = DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + + final JsonObject payload = new JsonObject(); + payload.addProperty("locale", "en"); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("closedOnDate", today); + // Unconditional and never skippable -- closure always withdraws the full balance. + payload.addProperty("withdrawBalance", true); + + final CommandWrapper commandRequest = new CommandWrapperBuilder() + .closeSavingsAccountApplication(savingsAccountId) + .withJson(payload.toString()) + .build(); + + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + if (result == null) { + throw apiError(500, "Closure command did not return a result for account " + savingsAccountId); + } + } + + /** + * Raises the closure's entry in the central voucher register, mirroring what an ordinary counter + * withdrawal gets. Closing an account bypasses the {@code withdrawal} command handler -- close() + * calls {@code savingsAccountDomainService.handleWithdrawal} directly -- so the generic + * {@code raiseSavingsVoucher} on that handler never runs and a closure would otherwise pay real + * cash out with nothing in the voucher register at all. + * + * Three lines, because the cash handed over is the ROUNDED figure while the member's ledger gave + * up the exact one: + *
+     *   Dr  Savings Control   closing_amount            (money leaving the member)
+     *   Dr  Round Off Account rounding_amount           (only when rounded up)
+     *   Cr  Cash in Hand      cashier_disbursed_amount  (what the till actually pays out)
+     * 
+ * Rounded down, the Round Off line moves to the credit side instead. This matches the journal + * entries close() and postRoundingAdjustment() actually post. + * + * Called from disburse(), not approve(): a voucher records cash crossing the counter, so it is + * raised when the cashier actually hands the notes over, in that cashier's own name. A closure + * that never reaches a cashier (a nil-balance account, closed outright at approval) correctly + * gets no voucher, because no cash ever moved. + * + * A failure here must not fail the disbursement: the cash is already in the member's hands and + * the account is closed. The problem is logged and the request keeps its (null) voucher number. + */ + private String raiseClosureVoucher(final Long requestId, final Long savingsAccountId, final Long savingsTxnId, + final Map record, final BigDecimal closingAmount, final BigDecimal roundingAmount, + final BigDecimal cashierDisbursedAmount, final AppUser currentUser) { + if (!voucherService.shouldRaiseVoucher() || closingAmount.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + try { + final Map control = voucherService.savingsControlAccount(savingsAccountId); + final Long controlGlId = control.get("id") == null ? null : ((Number) control.get("id")).longValue(); + final String memberSideName = record.get("client_name") != null ? String.valueOf(record.get("client_name")) + : (String) control.get("name"); + final String accountNo = (String) record.get("account_no"); + + final List lines = new ArrayList<>(); + lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount)); + final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO); + if (roundingDirection > 0) { + lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); + } + lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount)); + if (roundingDirection < 0) { + lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); + } + + final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, + VoucherService.REF_CLERK_CLOSURE_REQUEST, requestId, currentUser, VoucherService.PREFIX_DEBIT, lines); + // Tie the voucher to the savings transaction close() posted, so the two reconcile from + // either side -- this also stamps voucher_no onto the transaction itself. + voucherService.linkSavingsTransaction(VoucherService.REF_CLERK_CLOSURE_REQUEST, requestId, savingsTxnId); + return voucherNo; + } catch (final RuntimeException e) { + LOG.error("Could not raise a voucher for closure request {}", requestId, e); + return null; + } + } + + /** + * Posts one small adjusting journal entry so the bank's own Cash GL absorbs the paise rounded + * away for physical hand-over, without ever touching the member's own (paisa-correct) ledger. + * + * Cash is an asset, so cash LEAVING the drawer is a CREDIT to it — which is exactly what + * close() itself posts for the withdrawal (Dr Savings Control / Cr Cash). A positive + * roundingAmount means the cashier hands over MORE than the exact balance (rounded up), so that + * extra cash out is a further CREDIT to cash, against a DEBIT to Round Off (the bank wears the + * difference as an expense). Rounded down, both legs flip. + */ + private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser) { + final Map cashGl = resolveCashGlAccount(); + final Long roundOffGlId = resolveRoundOffGlAccount(); + final String currencyCode = jdbcTemplate.queryForObject( + "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); + final BigDecimal amount = roundingAmount.abs(); + // Same business date as the closure this is adjusting, so the two never land on + // different days in the day book. + final LocalDate entryDate = DateUtils.getBusinessLocalDate(); + final String transactionId = "CLOSEROUND-" + officeId + "-" + entryDate.toString().replace("-", "") + "-" + System.nanoTime(); + + final Long cashGlId = ((Number) cashGl.get("id")).longValue(); + // type_enum: 2 = DEBIT, 1 = CREDIT. + if (roundingAmount.compareTo(BigDecimal.ZERO) > 0) { + // Rounded up: extra cash left the drawer. Dr Round Off / Cr Cash + insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); + insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); + } else { + // Rounded down: less cash left the drawer than the member's ledger gave up. + // Dr Cash / Cr Round Off + insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); + insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); + } + } + + private Map resolveCashGlAccount() { + final List> rows = jdbcTemplate.queryForList(""" + SELECT g.id, g.name FROM acc_gl_financial_activity_account f + JOIN acc_gl_account g ON g.id = f.gl_account_id + WHERE f.financial_activity_type IN (101, 102) + ORDER BY f.financial_activity_type LIMIT 1 + """); + if (rows.isEmpty()) { + throw apiError(500, "No Cash at Vault / Cash at Teller account is configured for this tenant yet."); + } + return rows.get(0); + } + + private Long resolveRoundOffGlAccount() { + final List ids = jdbcTemplate.queryForList( + "SELECT id FROM acc_gl_account WHERE gl_code = '501200' LIMIT 1", Long.class); + if (ids.isEmpty()) { + throw apiError(500, "Round Off Account (gl_code 501200) is not configured for this tenant yet."); + } + return ids.get(0); + } + + /** type_enum: 2 = DEBIT, 1 = CREDIT (matches org.apache.fineract.accounting.journalentry.domain.JournalEntryType). */ + private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode, + final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount, + final AppUser currentUser) { + jdbcTemplate.update(""" + INSERT INTO acc_gl_journal_entry + (office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, + amount, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, + last_modified_on_utc, submitted_on_date, transaction_date) + VALUES (?,?,?,?,false,true,?,?,?,?,?,now(),now(),now(),now(),?,?) + """, officeId, accountId, currencyCode, transactionId, Date.valueOf(entryDate), typeEnum, amount, + currentUser.getId(), currentUser.getId(), Date.valueOf(DateUtils.getBusinessLocalDate()), Date.valueOf(entryDate)); + } + + private Map loadSavingsAccountSummary(final Long savingsAccountId) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT sa.id, sa.account_no, sa.status_enum, sa.client_id, + COALESCE(c.display_name, g.display_name) AS client_name + FROM m_savings_account sa + LEFT JOIN m_client c ON c.id = sa.client_id + LEFT JOIN m_group g ON g.id = sa.group_id + WHERE sa.id = ? + """, savingsAccountId); + return rows.isEmpty() ? null : rows.get(0); + } + + /** 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 Long paymentTypeId) { + if (paymentTypeId == null) return true; // null == "Cash (Default)" in the UI + final List rows = jdbcTemplate.queryForList( + "SELECT is_cash_payment FROM m_payment_type WHERE id = ?", Boolean.class, paymentTypeId); + return !rows.isEmpty() && Boolean.TRUE.equals(rows.get(0)); + } + + /** Saves the cashier's denomination breakdown into m_savings_txn_denomination so the vault + * position report reflects the cash paid out. denominationData: {"note:500": 2, ...}. */ + private void saveDisbursementDenomination(final String denominationData, final Map record, final AppUser cashier) { + final JsonObject denomMap; + try { + denomMap = gson.fromJson(denominationData, JsonObject.class); + } catch (Exception e) { + return; + } + 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; + + final Long savingsAccountId = ((Number) record.get("savings_account_id")).longValue(); + 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 String paymentTypeName = (String) record.get("payment_type_name"); + + 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); + 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); + + // txn_type is 'WITHDRAWAL', not a closure-specific label: this column records the + // direction cash moved through the drawer, and every consumer of this table (the branch + // day cash-out tally, the denomination and cashier-position reports, cash reconciliation, + // DenominationBreakdownWriter) filters on exactly 'DEPOSIT'/'WITHDRAWAL'. A new value + // here would leave this cash physically gone but invisible to all of them, so the day + // would never balance. The fact that it was a closure is recorded on + // m_clerk_closure_request itself, which is where that belongs. + 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, payment_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?, ?) + """, + txnId, savingsAccountId, accountNo, clientId, denomId, + value, count, BigDecimal.valueOf((long) value * count), + cashier.getId(), cashier.getUsername(), paymentTypeName + ); + } + } + + private Map loadRecord(final Long id) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT id, savings_account_id, account_no, client_id, client_name, closure_date, + payment_type_id, payment_type_name, description, tok_no, status, + clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, + rejection_reason, txn_id, closing_amount, rounding_amount, cashier_disbursed_amount, + cashier_id, disbursed_by, disbursed_at, disbursement_denomination_data, + voucher_no, activity_log, submitted_at, actioned_at + FROM m_clerk_closure_request WHERE id = ? + """, id); + if (rows.isEmpty()) { + throw new NotFoundException("Closure request not found: " + id); + } + return rows.get(0); + } + + /** + * Ensures the token number is unique for today's date among non-rejected requests across all + * three token pools that share this app's Transaction Entry token field: this table, cash + * withdrawal requests, and share dividend claims. + */ + private void validateTokenUniqueToday(final String tokNo) { + final long own = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_clerk_closure_request " + + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", + Long.class, tokNo) + ).orElse(0L); + final long withdrawal = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_clerk_withdrawal_request " + + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", + Long.class, tokNo) + ).orElse(0L); + final long dividend = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_dividend_claim_request " + + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", + Long.class, tokNo) + ).orElse(0L); + if (own + withdrawal + dividend > 0) { + throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number."); + } + } + + /** + * Checker authorization: only manager-level users may approve or reject. Allowed: super users + * (ALL_FUNCTIONS), or any role whose name contains "manager" or "head cashier". + */ + 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 closure requests."); + } + } + + 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); + } + + /** Clean JSON error the UI can display -- avoids a 500 + stack trace from the default exception mapper. */ + 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() + ); + } +} 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 7765e417e..2dafeb28b 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 @@ -850,9 +850,9 @@ public class ClerkWithdrawalRequestApiResource { /** * Ensures the token number is unique for today's date among non-rejected requests, checking - * both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token - * is redeemed at the very same Transaction Entry field, so the two pools must never collide. - * {@code excludeId} is set during resubmit to skip the record being edited. + * this table plus {@code m_share_dividend_claim_request} and {@code m_clerk_closure_request} -- + * all three are redeemed at the very same Transaction Entry token field, so none of the pools + * may ever collide. {@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( @@ -867,13 +867,19 @@ public class ClerkWithdrawalRequestApiResource { final long ownCount = Optional.ofNullable( jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray()) ).orElse(0L); - final long otherCount = Optional.ofNullable( + final long dividendCount = Optional.ofNullable( jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM m_share_dividend_claim_request " + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo) ).orElse(0L); - if (ownCount + otherCount > 0) { + final long closureCount = Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_clerk_closure_request " + + "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", + Long.class, tokNo) + ).orElse(0L); + if (ownCount + dividendCount + closureCount > 0) { 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 index e56d5981f..b07bb1061 100644 --- 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 @@ -63,6 +63,7 @@ public class VoucherService { public static final String TYPE_REPAYMENT = "REPAYMENT"; public static final String TYPE_INTEREST_POSTING = "INTEREST_POSTING"; public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST"; + public static final String REF_CLERK_CLOSURE_REQUEST = "CLERK_CLOSURE_REQUEST"; public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST"; public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST"; public static final String REF_BANK_TRANSFER_LOG = "BANK_TRANSFER_LOG"; diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index ed092bed4..5c0ea6560 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -51,4 +51,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_create_clerk_closure_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_create_clerk_closure_request.xml new file mode 100644 index 000000000..2413df6d0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_create_clerk_closure_request.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_seed_round_off_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_seed_round_off_account.xml new file mode 100644 index 000000000..713cc4924 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_seed_round_off_account.xml @@ -0,0 +1,24 @@ + + + + + + + SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '501200' + + + INSERT INTO acc_gl_account + (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) + VALUES ('Round Off Account', '501200', false, true, 1, 5); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_clerk_closure_request_voucher_no.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_clerk_closure_request_voucher_no.xml new file mode 100644 index 000000000..042b038f0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_clerk_closure_request_voucher_no.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + +