feat(clerk): add GL disbursement maker-checker backend
- Add ClerkDisbursementRequestApiResource with submit/list/my/approve/reject endpoints - Approve posts savings/current deposits then offsets DR source GL / CR Cash at Vault - Add Liquibase migration for m_clerk_disbursement_request table (IF NOT EXISTS indexes) - Add VoucherService constants TYPE_DISBURSEMENT and REF_CLERK_DISBURSEMENT_REQUEST Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3e1abaef03
commit
a6eae3d6e8
+593
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* 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.clerkdisbursement.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.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/clerk/disbursement-requests")
|
||||
@Component
|
||||
@Tag(name = "Clerk Disbursement Requests", description = "Maker-checker GL-to-account disbursement workflow for clerk and bank manager.")
|
||||
@RequiredArgsConstructor
|
||||
public class ClerkDisbursementRequestApiResource {
|
||||
|
||||
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/clerk/disbursement-requests ── Clerk submits new request ────────
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String submit(final String body) {
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
|
||||
final String glAccountNo = fromJsonHelper.extractStringNamed("glAccountNo", root);
|
||||
final String glAccountName = fromJsonHelper.extractStringNamed("glAccountName", root);
|
||||
final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root);
|
||||
final Long officeId = fromJsonHelper.extractLongNamed("officeId", root);
|
||||
final String currencyCode = Optional.ofNullable(
|
||||
fromJsonHelper.extractStringNamed("currencyCode", root)).orElse("INR");
|
||||
final Long paymentTypeId = fromJsonHelper.extractLongNamed("paymentTypeId", root);
|
||||
final String paymentTypeName = fromJsonHelper.extractStringNamed("paymentTypeName", root);
|
||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
|
||||
|
||||
if (glAccountNo == null || glAccountNo.isBlank()) {
|
||||
throw apiError(400, "GL account number (DR side) is required");
|
||||
}
|
||||
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
|
||||
throw apiError(400, "At least one credit entry (CR side) is required");
|
||||
}
|
||||
|
||||
// Parse and validate credit entries
|
||||
final JsonArray creditEntries;
|
||||
try {
|
||||
creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class);
|
||||
} catch (Exception e) {
|
||||
throw apiError(400, "creditEntries is not valid JSON");
|
||||
}
|
||||
if (creditEntries == null || creditEntries.size() == 0) {
|
||||
throw apiError(400, "At least one credit entry (CR side) is required");
|
||||
}
|
||||
|
||||
BigDecimal totalCr = BigDecimal.ZERO;
|
||||
for (final JsonElement el : creditEntries) {
|
||||
final JsonObject entry = el.getAsJsonObject();
|
||||
final String amt = entry.has("amount") ? entry.get("amount").getAsString() : null;
|
||||
final BigDecimal entryAmt = parseBigDecimal(amt);
|
||||
if (entryAmt == null || entryAmt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw apiError(400, "Each credit entry must have a positive amount");
|
||||
}
|
||||
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
|
||||
throw apiError(400, "Each credit entry must have an account number");
|
||||
}
|
||||
totalCr = totalCr.add(entryAmt);
|
||||
}
|
||||
|
||||
if (totalCr.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw apiError(400, "Total credit amount must be greater than zero");
|
||||
}
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_clerk_disbursement_request
|
||||
(gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code,
|
||||
credit_entries, payment_type_id, payment_type_name, description,
|
||||
status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
glAccountId, glAccountNo, glAccountName, totalCr,
|
||||
officeId != null ? officeId : 1L, currencyCode,
|
||||
creditEntriesJson, paymentTypeId, paymentTypeName, description,
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
activityLog, Timestamp.from(Instant.now())
|
||||
);
|
||||
|
||||
final String voucherNo = voucherService.createVoucher(
|
||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
||||
newId, totalCr, currentUser);
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING",
|
||||
"voucherNo", voucherNo, "totalAmount", totalCr));
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/disbursement-requests ── Manager sees all requests ─────────
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(
|
||||
@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
baseParams.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
baseParams.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_clerk_disbursement_request " + where,
|
||||
Long.class, baseParams.toArray())
|
||||
).orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(baseParams);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount,"
|
||||
+ " credit_entries, payment_type_name, description,"
|
||||
+ " clerk_id, clerk_name, clerk_username,"
|
||||
+ " manager_id, manager_name, manager_username,"
|
||||
+ " rejection_reason, txn_refs, submitted_at, actioned_at"
|
||||
+ " FROM m_clerk_disbursement_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/disbursement-requests/my ── Clerk's own requests ───────────
|
||||
|
||||
@GET
|
||||
@Path("/my")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String myRequests(
|
||||
@QueryParam("status") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
|
||||
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
|
||||
|
||||
if (status != null && !status.isBlank()) {
|
||||
where.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) >= ?");
|
||||
params.add(Date.valueOf(fromDate));
|
||||
}
|
||||
if (toDate != null && !toDate.isBlank()) {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
params.add(Date.valueOf(toDate));
|
||||
}
|
||||
|
||||
final long total = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_clerk_disbursement_request " + where,
|
||||
Long.class, params.toArray())
|
||||
).orElse(0L);
|
||||
|
||||
final List<Object> pageParams = new ArrayList<>(params);
|
||||
pageParams.add(limit);
|
||||
pageParams.add(offset);
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount,"
|
||||
+ " credit_entries, payment_type_name, description, clerk_name,"
|
||||
+ " manager_name, rejection_reason, txn_refs, submitted_at, actioned_at"
|
||||
+ " FROM m_clerk_disbursement_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/disbursement-requests/{id} ── Full detail ──────────────────
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getById(@PathParam("id") final Long id) {
|
||||
return gson.toJson(loadRecord(id));
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/disbursement-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 disbursement request you submitted yourself.");
|
||||
}
|
||||
|
||||
final String creditEntriesJson = (String) record.get("credit_entries");
|
||||
final JsonArray creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class);
|
||||
final Object ptId = record.get("payment_type_id");
|
||||
final String description = (String) record.get("description");
|
||||
final String txnDateStr = LocalDate.now().toString();
|
||||
|
||||
// Validate all entries have account IDs before posting any transaction
|
||||
for (int i = 0; i < creditEntries.size(); i++) {
|
||||
final JsonObject entry = creditEntries.get(i).getAsJsonObject();
|
||||
final boolean hasId = entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull();
|
||||
if (!hasId) {
|
||||
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "entry " + (i + 1);
|
||||
throw apiError(400, "Account ID missing for credit entry: " + acNo
|
||||
+ ". Clerk must re-submit with account details loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
final List<String> txnRefs = new ArrayList<>();
|
||||
final List<String> savingsTxnRefs = new ArrayList<>();
|
||||
|
||||
// Post savings/current deposit for each CR entry — all or nothing (rollback on any failure)
|
||||
for (final JsonElement el : creditEntries) {
|
||||
final JsonObject entry = el.getAsJsonObject();
|
||||
final Long savingsAccountId = entry.get("savingsAccountId").getAsLong();
|
||||
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
|
||||
final Long entryPtId = entry.has("paymentTypeId") && !entry.get("paymentTypeId").isJsonNull()
|
||||
? entry.get("paymentTypeId").getAsLong()
|
||||
: (ptId != null ? ((Number) ptId).longValue() : null);
|
||||
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(savingsAccountId);
|
||||
|
||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
|
||||
final String txnId;
|
||||
try {
|
||||
if (isCurrentAccount) {
|
||||
txnId = executeCurrentAccountDeposit(savingsAccountId, entryAmt, entryPtId, txnDateStr, description);
|
||||
} else {
|
||||
txnId = executeSavingsDeposit(savingsAccountId, entryAmt, entryPtId, txnDateStr, description);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Rethrow so @Transactional rolls back all previously posted deposits
|
||||
throw apiError(422, "Failed to credit account " + acNo + ": "
|
||||
+ (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
|
||||
}
|
||||
if (txnId != null) {
|
||||
txnRefs.add(txnId);
|
||||
if (!isCurrentAccount) savingsTxnRefs.add(txnId);
|
||||
}
|
||||
}
|
||||
|
||||
// Post offsetting GL journal entry: DR source GL / CR Cash at Vault
|
||||
// Fineract's savings deposit auto-creates "DR Cash at Vault / CR Savings" via product GL mapping.
|
||||
// This offsetting entry makes the net accounting: DR source GL / CR savings accounts.
|
||||
final Long sourceGlId = record.get("gl_account_id") != null
|
||||
? ((Number) record.get("gl_account_id")).longValue() : null;
|
||||
if (sourceGlId != null && !savingsTxnRefs.isEmpty()) {
|
||||
Long cashVaultGlId = null;
|
||||
for (final String txnRef : savingsTxnRefs) {
|
||||
final List<Map<String, Object>> jeRows = jdbcTemplate.queryForList(
|
||||
"SELECT account_id FROM acc_gl_journal_entry"
|
||||
+ " WHERE transaction_id = ? AND type_enum = 1 LIMIT 1", "S" + txnRef);
|
||||
if (!jeRows.isEmpty()) {
|
||||
cashVaultGlId = ((Number) jeRows.get(0).get("account_id")).longValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cashVaultGlId != null) {
|
||||
final BigDecimal totalAmount = (BigDecimal) record.get("dr_amount");
|
||||
final Long officeId = record.get("office_id") != null
|
||||
? ((Number) record.get("office_id")).longValue() : 1L;
|
||||
final String currencyCode = record.get("currency_code") != null
|
||||
? (String) record.get("currency_code") : "INR";
|
||||
postGlOffsetEntry(sourceGlId, cashVaultGlId, totalAmount, officeId,
|
||||
currencyCode, txnDateStr, description);
|
||||
}
|
||||
}
|
||||
|
||||
final String txnRefsStr = String.join(",", txnRefs);
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
||||
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_clerk_disbursement_request SET
|
||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||
txn_refs = ?, activity_log = ?, actioned_at = ?
|
||||
WHERE id = ? AND status = 'PENDING'
|
||||
""",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id
|
||||
);
|
||||
if (updated == 0) {
|
||||
throw apiError(409, "This disbursement request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
final JsonObject res = new JsonObject();
|
||||
res.addProperty("resourceId", id);
|
||||
res.addProperty("status", "APPROVED");
|
||||
res.addProperty("txnRefs", txnRefsStr);
|
||||
return gson.toJson(res);
|
||||
}
|
||||
|
||||
// ── POST /v1/clerk/disbursement-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_clerk_disbursement_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 disbursement request was already actioned by another manager.");
|
||||
}
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 disbursement requests.");
|
||||
}
|
||||
}
|
||||
|
||||
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 Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, voucher_no, status, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
||||
office_id, currency_code, credit_entries, payment_type_id, payment_type_name,
|
||||
description, clerk_id, clerk_name, clerk_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason,
|
||||
txn_refs, activity_log, submitted_at, actioned_at
|
||||
FROM m_clerk_disbursement_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Disbursement request not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private String executeSavingsDeposit(
|
||||
final Long savingsAccountId, final BigDecimal amount,
|
||||
final Long paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String date = formatForFineract(txnDateStr);
|
||||
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", paymentTypeId);
|
||||
if (note != null && !note.isBlank()) payload.addProperty("note", note);
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder()
|
||||
.savingsAccountDeposit(savingsAccountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private String executeCurrentAccountDeposit(
|
||||
final Long currentAccountId, final BigDecimal amount,
|
||||
final Long paymentTypeId, final String txnDateStr, final String note) {
|
||||
|
||||
final String date = formatForFineract(txnDateStr);
|
||||
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", paymentTypeId);
|
||||
if (note != null && !note.isBlank()) payload.addProperty("note", note);
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder()
|
||||
.currentAccountDeposit(currentAccountId)
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||
}
|
||||
|
||||
private boolean isCurrentAccountById(final Long accountId) {
|
||||
try {
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_current_account WHERE id = ?", Integer.class, accountId);
|
||||
return count != null && count > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private void postGlOffsetEntry(
|
||||
final Long drGlAccountId, final Long crGlAccountId,
|
||||
final BigDecimal amount, final Long officeId,
|
||||
final String currencyCode, final String txnDateStr, final String comments) {
|
||||
|
||||
final JsonObject debitEntry = new JsonObject();
|
||||
debitEntry.addProperty("glAccountId", drGlAccountId);
|
||||
debitEntry.addProperty("amount", amount);
|
||||
|
||||
final JsonObject creditEntry = new JsonObject();
|
||||
creditEntry.addProperty("glAccountId", crGlAccountId);
|
||||
creditEntry.addProperty("amount", amount);
|
||||
|
||||
final JsonArray debits = new JsonArray();
|
||||
debits.add(debitEntry);
|
||||
final JsonArray credits = new JsonArray();
|
||||
credits.add(creditEntry);
|
||||
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("officeId", officeId);
|
||||
payload.addProperty("transactionDate", formatForFineract(txnDateStr));
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("currencyCode", currencyCode != null ? currencyCode : "INR");
|
||||
if (comments != null && !comments.isBlank()) {
|
||||
payload.addProperty("comments", "Disbursement GL offset: " + comments);
|
||||
}
|
||||
payload.add("debits", debits);
|
||||
payload.add("credits", credits);
|
||||
|
||||
final CommandWrapper cmd = new CommandWrapperBuilder()
|
||||
.createJournalEntry()
|
||||
.withJson(payload.toString())
|
||||
.build();
|
||||
commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||
}
|
||||
}
|
||||
+2
@@ -42,8 +42,10 @@ public class VoucherService {
|
||||
|
||||
public static final String TYPE_WITHDRAWAL = "WITHDRAWAL";
|
||||
public static final String TYPE_DEPOSIT = "DEPOSIT";
|
||||
public static final String TYPE_DISBURSEMENT = "DISBURSEMENT";
|
||||
public static final String REF_CLERK_WITHDRAWAL_REQUEST = "CLERK_WITHDRAWAL_REQUEST";
|
||||
public static final String REF_CASHIER_DEPOSIT_REQUEST = "CASHIER_DEPOSIT_REQUEST";
|
||||
public static final String REF_CLERK_DISBURSEMENT_REQUEST = "CLERK_DISBURSEMENT_REQUEST";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
|
||||
@@ -359,4 +359,5 @@
|
||||
<include file="parts/0329_create_voucher.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0330_create_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0331_add_gl_columns_to_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0332_create_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="0332_create_clerk_disbursement_request" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_clerk_disbursement_request"/></not>
|
||||
</preConditions>
|
||||
|
||||
<createTable tableName="m_clerk_disbursement_request">
|
||||
<column name="id" type="BIGSERIAL">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="voucher_no" type="VARCHAR(20)"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- DR side: GL account info -->
|
||||
<column name="gl_account_id" type="BIGINT"/>
|
||||
<column name="gl_account_no" type="VARCHAR(50)"/>
|
||||
<column name="gl_account_name" type="VARCHAR(150)"/>
|
||||
<column name="dr_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<column name="currency_code" type="VARCHAR(10)" defaultValue="INR"/>
|
||||
|
||||
<!-- CR side: JSON array of credit entries -->
|
||||
<!-- Format: [{"accountNo":"0032","accountType":"SD","savingsAccountId":123,"amount":5000,"paymentTypeId":1}] -->
|
||||
<column name="credit_entries" type="TEXT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<column name="payment_type_id" type="BIGINT"/>
|
||||
<column name="payment_type_name" type="VARCHAR(100)"/>
|
||||
<column name="description" type="VARCHAR(500)"/>
|
||||
|
||||
<!-- Maker (clerk) -->
|
||||
<column name="clerk_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="clerk_name" type="VARCHAR(150)"/>
|
||||
<column name="clerk_username" type="VARCHAR(100)"/>
|
||||
|
||||
<!-- Checker (manager) -->
|
||||
<column name="manager_id" type="BIGINT"/>
|
||||
<column name="manager_name" type="VARCHAR(150)"/>
|
||||
<column name="manager_username" type="VARCHAR(100)"/>
|
||||
<column name="rejection_reason" type="VARCHAR(500)"/>
|
||||
|
||||
<!-- Result: comma-separated txn refs (one per CR entry) -->
|
||||
<column name="txn_refs" type="VARCHAR(500)"/>
|
||||
|
||||
<column name="activity_log" type="TEXT"/>
|
||||
<column name="submitted_at" type="TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="actioned_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
|
||||
<sql>CREATE INDEX IF NOT EXISTS idx_cdr_status ON m_clerk_disbursement_request(status)</sql>
|
||||
<sql>CREATE INDEX IF NOT EXISTS idx_cdr_clerk_id ON m_clerk_disbursement_request(clerk_id)</sql>
|
||||
<sql>CREATE INDEX IF NOT EXISTS idx_cdr_submitted_at ON m_clerk_disbursement_request(submitted_at)</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user