From a6eae3d6e88be9e04aea7ab8cd092112447f9ba8 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Tue, 16 Jun 2026 10:44:04 +0530 Subject: [PATCH] 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 --- .../ClerkDisbursementRequestApiResource.java | 593 ++++++++++++++++++ .../voucher/service/VoucherService.java | 2 + .../db/changelog/tenant/changelog-tenant.xml | 1 + ...0332_create_clerk_disbursement_request.xml | 69 ++ 4 files changed, 665 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0332_create_clerk_disbursement_request.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java new file mode 100644 index 000000000..b1d9248e9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -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 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 pageParams = new ArrayList<>(baseParams); + pageParams.add(limit); + pageParams.add(offset); + + final List> 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 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 pageParams = new ArrayList<>(params); + pageParams.add(limit); + pageParams.add(offset); + + final List> 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 txnRefs = new ArrayList<>(); + final List 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> 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> 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); + } +} 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 0af847350..c243e9034 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 @@ -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; 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 ce2e851e2..0536d80df 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 @@ -359,4 +359,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0332_create_clerk_disbursement_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0332_create_clerk_disbursement_request.xml new file mode 100644 index 000000000..15e309dc1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0332_create_clerk_disbursement_request.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CREATE INDEX IF NOT EXISTS idx_cdr_status ON m_clerk_disbursement_request(status) + CREATE INDEX IF NOT EXISTS idx_cdr_clerk_id ON m_clerk_disbursement_request(clerk_id) + CREATE INDEX IF NOT EXISTS idx_cdr_submitted_at ON m_clerk_disbursement_request(submitted_at) + + +