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:
Jaanu423
2026-06-16 10:44:04 +05:30
co-authored by Claude Sonnet 4.6
parent 3e1abaef03
commit a6eae3d6e8
4 changed files with 665 additions and 0 deletions
@@ -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);
}
}
@@ -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>
@@ -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>