feat(clerk+gl): implement real CBS book transfer for savings and current account disbursements
- Replace Fineract cash deposit API with direct INSERT for both savings and current accounts → Zero CashVault entries created anywhere in DB for disbursements - Savings: directCreditSavingsAccount now sets running_balance_derived and updates total_deposits_derived alongside account_balance_derived (parity with current account) - Current: directCreditCurrentAccount — direct INSERT into m_current_account_transaction with running_balance_derived, total_deposits_derived update, and remarks audit field - getCurrentControlGlId: looks up SAVINGS_CONTROL GL for current account products via acc_product_mapping (product_type=2, financial_account_type=2) - directReverseCurrentCredit: compensation restores both account_balance_derived and total_deposits_derived on rollback - GL: combined DR sourceGL / CR SavingsControl entry posted for both savings and current account credits; linkGlEntriesToSavingsAccounts sets entity_type_enum=2 so ACCOUNT NO column shows in journal entries view - Hide reversed=true GL entries from all GL views in JournalEntryReadPlatformServiceImpl Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
d002fc0c9b
commit
bf5ce421df
+8
-4
@@ -264,6 +264,10 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
int arrayPos = 0;
|
int arrayPos = 0;
|
||||||
String whereClose = " where ";
|
String whereClose = " where ";
|
||||||
|
|
||||||
|
// Exclude intermediary/reversed entries from all GL views
|
||||||
|
sqlBuilder.append(whereClose).append("(journalEntry.reversed IS NULL OR journalEntry.reversed = false)");
|
||||||
|
whereClose = " and ";
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(transactionId)) {
|
if (StringUtils.isNotBlank(transactionId)) {
|
||||||
sqlBuilder.append(whereClose).append(" journalEntry.transaction_id = ?");
|
sqlBuilder.append(whereClose).append(" journalEntry.transaction_id = ?");
|
||||||
objectArray[arrayPos] = transactionId;
|
objectArray[arrayPos] = transactionId;
|
||||||
@@ -580,7 +584,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
"SELECT COALESCE(SUM(CASE WHEN je.type_enum = 2 THEN je.amount ELSE 0 END), 0) AS ob_debit, "
|
"SELECT COALESCE(SUM(CASE WHEN je.type_enum = 2 THEN je.amount ELSE 0 END), 0) AS ob_debit, "
|
||||||
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS ob_credit "
|
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS ob_credit "
|
||||||
+ "FROM acc_gl_journal_entry je "
|
+ "FROM acc_gl_journal_entry je "
|
||||||
+ "WHERE je.account_id = ? AND je.entry_date < ?");
|
+ "WHERE je.account_id = ? AND je.entry_date < ? AND (je.reversed IS NULL OR je.reversed = false)");
|
||||||
obParams.add(glAccountId);
|
obParams.add(glAccountId);
|
||||||
obParams.add(fromDate);
|
obParams.add(fromDate);
|
||||||
if (officeId != null && officeId > 0) {
|
if (officeId != null && officeId > 0) {
|
||||||
@@ -607,7 +611,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS dw_credit, "
|
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS dw_credit, "
|
||||||
+ "COUNT(*) AS dw_count "
|
+ "COUNT(*) AS dw_count "
|
||||||
+ "FROM acc_gl_journal_entry je "
|
+ "FROM acc_gl_journal_entry je "
|
||||||
+ "WHERE 1=1");
|
+ "WHERE (je.reversed IS NULL OR je.reversed = false)");
|
||||||
if (glAccountId != null && glAccountId > 0) {
|
if (glAccountId != null && glAccountId > 0) {
|
||||||
dwSql.append(" AND je.account_id = ?");
|
dwSql.append(" AND je.account_id = ?");
|
||||||
dwParams.add(glAccountId);
|
dwParams.add(glAccountId);
|
||||||
@@ -683,7 +687,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
final StringBuilder totSql = new StringBuilder(
|
final StringBuilder totSql = new StringBuilder(
|
||||||
"SELECT COALESCE(SUM(CASE WHEN je.type_enum = 2 THEN je.amount ELSE 0 END), 0) AS totalDebit, "
|
"SELECT COALESCE(SUM(CASE WHEN je.type_enum = 2 THEN je.amount ELSE 0 END), 0) AS totalDebit, "
|
||||||
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS totalCredit "
|
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS totalCredit "
|
||||||
+ "FROM acc_gl_journal_entry je WHERE 1=1");
|
+ "FROM acc_gl_journal_entry je WHERE (je.reversed IS NULL OR je.reversed = false)");
|
||||||
if (glAccountId != null && glAccountId > 0) {
|
if (glAccountId != null && glAccountId > 0) {
|
||||||
totSql.append(" AND je.account_id = ?");
|
totSql.append(" AND je.account_id = ?");
|
||||||
totParams.add(glAccountId);
|
totParams.add(glAccountId);
|
||||||
@@ -722,7 +726,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
|
|||||||
{
|
{
|
||||||
final List<Object> cntParams = new ArrayList<>();
|
final List<Object> cntParams = new ArrayList<>();
|
||||||
final StringBuilder cntSql = new StringBuilder(
|
final StringBuilder cntSql = new StringBuilder(
|
||||||
"SELECT COUNT(*) FROM acc_gl_journal_entry je WHERE 1=1");
|
"SELECT COUNT(*) FROM acc_gl_journal_entry je WHERE (je.reversed IS NULL OR je.reversed = false)");
|
||||||
if (glAccountId != null && glAccountId > 0) {
|
if (glAccountId != null && glAccountId > 0) {
|
||||||
cntSql.append(" AND je.account_id = ?");
|
cntSql.append(" AND je.account_id = ?");
|
||||||
cntParams.add(glAccountId);
|
cntParams.add(glAccountId);
|
||||||
|
|||||||
+605
-118
@@ -38,15 +38,22 @@ import jakarta.ws.rs.core.MediaType;
|
|||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.sql.Date;
|
import java.sql.Date;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||||
@@ -66,6 +73,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Component
|
@Component
|
||||||
@Tag(name = "Clerk Disbursement Requests", description = "Maker-checker GL-to-account disbursement workflow for clerk and bank manager.")
|
@Tag(name = "Clerk Disbursement Requests", description = "Maker-checker GL-to-account disbursement workflow for clerk and bank manager.")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class ClerkDisbursementRequestApiResource {
|
public class ClerkDisbursementRequestApiResource {
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
@@ -162,7 +170,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
""",
|
""",
|
||||||
Long.class,
|
Long.class,
|
||||||
glAccountId, glAccountNo, glAccountName, txnAmount,
|
glAccountId, glAccountNo, glAccountName, txnAmount,
|
||||||
officeId != null ? officeId : 1L, currencyCode,
|
officeId != null ? officeId : currentUser.getOffice().getId(), currencyCode,
|
||||||
creditEntriesJson, paymentTypeId, paymentTypeName, description,
|
creditEntriesJson, paymentTypeId, paymentTypeName, description,
|
||||||
"PENDING",
|
"PENDING",
|
||||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||||
@@ -181,6 +189,9 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
|
|
||||||
// ── GET /v1/clerk/disbursement-requests ── Manager sees all requests ─────────
|
// ── GET /v1/clerk/disbursement-requests ── Manager sees all requests ─────────
|
||||||
|
|
||||||
|
private static final java.util.Set<String> VALID_STATUSES = java.util.Set.of(
|
||||||
|
"PENDING", "APPROVED", "REJECTED", "FAILED", "PROCESSING");
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String list(
|
public String list(
|
||||||
@@ -190,15 +201,32 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
@QueryParam("fromDate") final String fromDate,
|
@QueryParam("fromDate") final String fromDate,
|
||||||
@QueryParam("toDate") final String toDate) {
|
@QueryParam("toDate") final String toDate) {
|
||||||
|
|
||||||
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
validateCheckerRole(currentUser);
|
||||||
|
|
||||||
|
if (!VALID_STATUSES.contains(status)) {
|
||||||
|
throw apiError(400, "Invalid status '" + status + "'. Must be one of: " + VALID_STATUSES);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int safeLimit = Math.min(limit, 200);
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
try {
|
||||||
baseParams.add(Date.valueOf(fromDate));
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
|
baseParams.add(Date.valueOf(fromDate));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw apiError(400, "Invalid fromDate format. Use yyyy-MM-dd.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (toDate != null && !toDate.isBlank()) {
|
if (toDate != null && !toDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) <= ?");
|
try {
|
||||||
baseParams.add(Date.valueOf(toDate));
|
where.append(" AND DATE(submitted_at) <= ?");
|
||||||
|
baseParams.add(Date.valueOf(toDate));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw apiError(400, "Invalid toDate format. Use yyyy-MM-dd.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final long total = Optional.ofNullable(
|
final long total = Optional.ofNullable(
|
||||||
@@ -208,7 +236,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
|
|
||||||
final List<Object> pageParams = new ArrayList<>(baseParams);
|
final List<Object> pageParams = new ArrayList<>(baseParams);
|
||||||
pageParams.add(limit);
|
pageParams.add(safeLimit);
|
||||||
pageParams.add(offset);
|
pageParams.add(offset);
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
@@ -216,7 +244,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
+ " credit_entries, payment_type_name, description,"
|
+ " credit_entries, payment_type_name, description,"
|
||||||
+ " clerk_id, clerk_name, clerk_username,"
|
+ " clerk_id, clerk_name, clerk_username,"
|
||||||
+ " manager_id, manager_name, manager_username,"
|
+ " manager_id, manager_name, manager_username,"
|
||||||
+ " rejection_reason, txn_refs, submitted_at, actioned_at"
|
+ " rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
|
||||||
+ " FROM m_clerk_disbursement_request " + where
|
+ " FROM m_clerk_disbursement_request " + where
|
||||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray());
|
pageParams.toArray());
|
||||||
@@ -240,20 +268,34 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
@QueryParam("toDate") final String toDate) {
|
@QueryParam("toDate") final String toDate) {
|
||||||
|
|
||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
|
||||||
|
final int safeLimit = Math.min(limit, 200);
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
|
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
|
||||||
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
|
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
|
||||||
|
|
||||||
if (status != null && !status.isBlank()) {
|
if (status != null && !status.isBlank()) {
|
||||||
|
if (!VALID_STATUSES.contains(status)) {
|
||||||
|
throw apiError(400, "Invalid status '" + status + "'. Must be one of: " + VALID_STATUSES);
|
||||||
|
}
|
||||||
where.append(" AND status = ?");
|
where.append(" AND status = ?");
|
||||||
params.add(status);
|
params.add(status);
|
||||||
}
|
}
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
try {
|
||||||
params.add(Date.valueOf(fromDate));
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
|
params.add(Date.valueOf(fromDate));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw apiError(400, "Invalid fromDate format. Use yyyy-MM-dd.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (toDate != null && !toDate.isBlank()) {
|
if (toDate != null && !toDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) <= ?");
|
try {
|
||||||
params.add(Date.valueOf(toDate));
|
where.append(" AND DATE(submitted_at) <= ?");
|
||||||
|
params.add(Date.valueOf(toDate));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw apiError(400, "Invalid toDate format. Use yyyy-MM-dd.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final long total = Optional.ofNullable(
|
final long total = Optional.ofNullable(
|
||||||
@@ -263,13 +305,13 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
|
|
||||||
final List<Object> pageParams = new ArrayList<>(params);
|
final List<Object> pageParams = new ArrayList<>(params);
|
||||||
pageParams.add(limit);
|
pageParams.add(safeLimit);
|
||||||
pageParams.add(offset);
|
pageParams.add(offset);
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
"SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount,"
|
"SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount,"
|
||||||
+ " credit_entries, payment_type_name, description, clerk_name,"
|
+ " credit_entries, payment_type_name, description, clerk_name,"
|
||||||
+ " manager_name, rejection_reason, txn_refs, submitted_at, actioned_at"
|
+ " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
|
||||||
+ " FROM m_clerk_disbursement_request " + where
|
+ " FROM m_clerk_disbursement_request " + where
|
||||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray());
|
pageParams.toArray());
|
||||||
@@ -286,7 +328,18 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String getById(@PathParam("id") final Long id) {
|
public String getById(@PathParam("id") final Long id) {
|
||||||
return gson.toJson(loadRecord(id));
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
// Manager (checker role) can view any record; clerk can only view their own.
|
||||||
|
final boolean isManager = isCheckerRole(currentUser);
|
||||||
|
if (!isManager) {
|
||||||
|
final Long clerkId = record.get("clerk_id") != null
|
||||||
|
? ((Number) record.get("clerk_id")).longValue() : null;
|
||||||
|
if (!currentUser.getId().equals(clerkId)) {
|
||||||
|
throw apiError(403, "You can only view your own disbursement requests.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gson.toJson(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
|
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
|
||||||
@@ -295,7 +348,9 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
@Path("/{id}/approve")
|
@Path("/{id}/approve")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Transactional
|
// NOT @Transactional: Fineract commands commit in their own REQUIRES_NEW transactions.
|
||||||
|
// Our DB status updates (PROCESSING → APPROVED/FAILED) must also commit independently
|
||||||
|
// so the status persists correctly even when an exception is thrown mid-processing.
|
||||||
public String approve(@PathParam("id") final Long id, final String body) {
|
public String approve(@PathParam("id") final Long id, final String body) {
|
||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
@@ -323,25 +378,34 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final String currencyCode = record.get("currency_code") != null
|
final String currencyCode = record.get("currency_code") != null
|
||||||
? (String) record.get("currency_code") : "INR";
|
? (String) record.get("currency_code") : "INR";
|
||||||
|
|
||||||
// Validate all entries have required IDs before posting any transaction
|
// Validate all entries have required IDs before acquiring the processing lock.
|
||||||
|
// Validation failures do not change status — request stays PENDING for clerk to fix.
|
||||||
for (int i = 0; i < creditEntries.size(); i++) {
|
for (int i = 0; i < creditEntries.size(); i++) {
|
||||||
final JsonObject entry = creditEntries.get(i).getAsJsonObject();
|
final JsonObject entry = creditEntries.get(i).getAsJsonObject();
|
||||||
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||||
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "entry " + (i + 1);
|
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "entry " + (i + 1);
|
||||||
if ("GL".equals(entryType)) {
|
if ("GL".equals(entryType)) {
|
||||||
if (!entry.has("glAccountId") || entry.get("glAccountId").isJsonNull()) {
|
if (!entry.has("glAccountId") || entry.get("glAccountId").isJsonNull()) {
|
||||||
throw apiError(400, "GL account ID missing for GL credit entry: " + acNo
|
throw apiError(400, "GL account ID missing for GL entry: " + acNo
|
||||||
+ ". Clerk must re-submit with account details loaded.");
|
+ ". Clerk must re-submit with account details loaded.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
|
if (!entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
|
||||||
throw apiError(400, "Account ID missing for credit entry: " + acNo
|
throw apiError(400, "Account ID missing for entry: " + acNo
|
||||||
+ ". Clerk must re-submit with account details loaded.");
|
+ ". Clerk must re-submit with account details loaded.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect collection mode: any DR entry in creditEntries = collection (savings debit)
|
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
|
||||||
|
// posting duplicate Fineract transactions for the same request.
|
||||||
|
final int locked = jdbcTemplate.update(
|
||||||
|
"UPDATE m_clerk_disbursement_request SET status = 'PROCESSING' WHERE id = ? AND status = 'PENDING'", id);
|
||||||
|
if (locked == 0) {
|
||||||
|
throw apiError(409, "This request is already being processed or has already been actioned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect collection mode: any DR entry = collection (savings debits → GL credit)
|
||||||
boolean isCollectionMode = false;
|
boolean isCollectionMode = false;
|
||||||
for (final JsonElement el : creditEntries) {
|
for (final JsonElement el : creditEntries) {
|
||||||
final JsonObject e = el.getAsJsonObject();
|
final JsonObject e = el.getAsJsonObject();
|
||||||
@@ -352,122 +416,197 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<String> txnRefs = new ArrayList<>();
|
final List<String> txnRefs = new ArrayList<>();
|
||||||
// Disbursement: savings deposits — for offset DR sourceGL / CR Cash@Vault
|
|
||||||
final List<String> depositSavingsTxnRefs = new ArrayList<>();
|
|
||||||
BigDecimal depositSavingsTotal = BigDecimal.ZERO;
|
BigDecimal depositSavingsTotal = BigDecimal.ZERO;
|
||||||
// Collection: savings withdrawals — for offset DR Cash@Vault / CR targetGL
|
|
||||||
final List<String> withdrawalSavingsTxnRefs = new ArrayList<>();
|
final List<String> withdrawalSavingsTxnRefs = new ArrayList<>();
|
||||||
BigDecimal withdrawalSavingsTotal = BigDecimal.ZERO;
|
BigDecimal withdrawalSavingsTotal = BigDecimal.ZERO;
|
||||||
Long collectionTargetGlId = null;
|
Long collectionTargetGlId = null;
|
||||||
|
|
||||||
// Process each entry — all or nothing (@Transactional rolls back on any failure)
|
// Savings-control GL data per account for the direct combined journal entry.
|
||||||
for (final JsonElement el : creditEntries) {
|
// collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode).
|
||||||
final JsonObject entry = el.getAsJsonObject();
|
// disbursementGlEntries: CR SavingsControl per savings account (disbursement/credit mode).
|
||||||
final String dbCr = entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr";
|
final List<GlEntry> collectionGlEntries = new ArrayList<>();
|
||||||
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
|
final List<GlEntry> disbursementGlEntries = new ArrayList<>();
|
||||||
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
|
||||||
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
|
// Compensation list: each successfully posted Fineract transaction gets an undo lambda.
|
||||||
final Long entryPtId = entry.has("paymentTypeId") && !entry.get("paymentTypeId").isJsonNull()
|
// On partial failure, these are run in reverse order (LIFO) for clean rollback.
|
||||||
? entry.get("paymentTypeId").getAsLong()
|
final List<Runnable> undoOps = new ArrayList<>();
|
||||||
: (ptId != null ? ((Number) ptId).longValue() : null);
|
|
||||||
|
try {
|
||||||
|
for (final JsonElement el : creditEntries) {
|
||||||
|
final JsonObject entry = el.getAsJsonObject();
|
||||||
|
final String dbCr = entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr";
|
||||||
|
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
|
||||||
|
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||||
|
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
|
||||||
|
final Long entryPtId = entry.has("paymentTypeId") && !entry.get("paymentTypeId").isJsonNull()
|
||||||
|
? entry.get("paymentTypeId").getAsLong()
|
||||||
|
: (ptId != null ? ((Number) ptId).longValue() : null);
|
||||||
|
|
||||||
|
if ("GL".equals(entryType)) {
|
||||||
|
final Long glId = entry.get("glAccountId").getAsLong();
|
||||||
|
if ("Cr".equals(dbCr)) {
|
||||||
|
if (!isCollectionMode && sourceGlId != null) {
|
||||||
|
// Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL
|
||||||
|
postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description);
|
||||||
|
} else {
|
||||||
|
// Collection: CR GL is the target for the combined savings debit entry
|
||||||
|
collectionTargetGlId = glId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txnRefs.add("GL:" + acNo);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long accountId = entry.get("savingsAccountId").getAsLong();
|
||||||
|
final boolean isCurrentAccount = isCurrentAccountById(accountId);
|
||||||
|
String txnId = null;
|
||||||
|
|
||||||
if ("GL".equals(entryType)) {
|
|
||||||
final Long glId = entry.get("glAccountId").getAsLong();
|
|
||||||
if ("Cr".equals(dbCr)) {
|
if ("Cr".equals(dbCr)) {
|
||||||
if (!isCollectionMode && sourceGlId != null) {
|
if (isCurrentAccount) {
|
||||||
// Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL
|
// Real CBS book transfer for current accounts: direct INSERT into
|
||||||
postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description);
|
// m_current_account_transaction + balance update. No Fineract cash
|
||||||
} else {
|
// deposit API → no CashVault entry created anywhere in the DB.
|
||||||
// Collection: CR GL is the target for the Cash@Vault offset
|
final Long caCtrlGl = getCurrentControlGlId(accountId);
|
||||||
collectionTargetGlId = glId;
|
if (caCtrlGl == null) {
|
||||||
}
|
throw new RuntimeException(
|
||||||
}
|
"No savings-control GL mapping found for current account " + acNo
|
||||||
txnRefs.add("GL:" + acNo);
|
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||||
continue;
|
}
|
||||||
}
|
final long directCaTxnId = directCreditCurrentAccount(
|
||||||
|
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
||||||
// Savings or Current account
|
txnRefs.add(String.valueOf(directCaTxnId));
|
||||||
final Long accountId = entry.get("savingsAccountId").getAsLong();
|
|
||||||
final boolean isCurrentAccount = isCurrentAccountById(accountId);
|
|
||||||
String txnId = null;
|
|
||||||
|
|
||||||
if ("Cr".equals(dbCr)) {
|
|
||||||
// Deposit — credit savings/current account (disbursement)
|
|
||||||
try {
|
|
||||||
txnId = isCurrentAccount
|
|
||||||
? executeCurrentAccountDeposit(accountId, entryAmt, entryPtId, txnDateStr, description)
|
|
||||||
: executeSavingsDeposit(accountId, entryAmt, entryPtId, txnDateStr, description);
|
|
||||||
} catch (IdempotentCommandProcessSucceedException idempotent) {
|
|
||||||
txnId = extractResourceIdFromResponse(idempotent.getResponse());
|
|
||||||
} catch (Exception ex) {
|
|
||||||
throw apiError(422, "Failed to credit account " + acNo + ": "
|
|
||||||
+ (ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName()));
|
|
||||||
}
|
|
||||||
if (txnId != null) {
|
|
||||||
txnRefs.add(txnId);
|
|
||||||
if (!isCurrentAccount) {
|
|
||||||
depositSavingsTxnRefs.add(txnId);
|
|
||||||
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
||||||
|
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId));
|
||||||
|
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
|
||||||
|
} else {
|
||||||
|
// Real CBS book transfer: direct credit to savings account.
|
||||||
|
// No Fineract cash deposit API → no CashVault entry created anywhere.
|
||||||
|
final Long savingsCtrlGl = getSavingsControlGlId(accountId);
|
||||||
|
if (savingsCtrlGl == null) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"No savings-control GL mapping found for account " + acNo
|
||||||
|
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||||
|
}
|
||||||
|
final long directTxnId = directCreditSavingsAccount(
|
||||||
|
accountId, entryAmt, officeId, txnDateStr, currentUser);
|
||||||
|
txnRefs.add(String.valueOf(directTxnId));
|
||||||
|
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
|
||||||
|
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId));
|
||||||
|
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
txnId = isCurrentAccount
|
||||||
|
? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description)
|
||||||
|
: executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description);
|
||||||
|
} catch (IdempotentCommandProcessSucceedException idempotent) {
|
||||||
|
txnId = extractResourceIdFromResponse(idempotent.getResponse());
|
||||||
|
}
|
||||||
|
if (txnId != null) {
|
||||||
|
registerUndo(undoOps, accountId, txnId, isCurrentAccount);
|
||||||
|
txnRefs.add(txnId);
|
||||||
|
if (!isCurrentAccount) {
|
||||||
|
final String fullTxnId = "S" + txnId;
|
||||||
|
withdrawalSavingsTxnRefs.add(txnId);
|
||||||
|
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
|
||||||
|
// Savings withdrawal auto-creates: DR SavingsControl / CR CashVault.
|
||||||
|
// Collect the SavingsControl GL (DR side, type_enum=2) so we can
|
||||||
|
// post a direct DR SavingsControl / CR targetGL combined entry.
|
||||||
|
final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2);
|
||||||
|
if (savingsCtrlGl != null) {
|
||||||
|
collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Withdrawal — debit savings/current account (collection)
|
|
||||||
|
// ── Disbursement GL entry ─────────────────────────────────────────────
|
||||||
|
// Real CBS book transfer: DR sourceGL / CR SavingsControl(per account).
|
||||||
|
// Both savings and current accounts use direct INSERT — no Fineract cash
|
||||||
|
// API involved → no CashVault entry created anywhere in the DB.
|
||||||
|
if (sourceGlId != null && !disbursementGlEntries.isEmpty()) {
|
||||||
|
final String disbGlTxnId = postCombinedJournalEntry(
|
||||||
|
List.of(new GlEntry(sourceGlId, depositSavingsTotal, null, null)),
|
||||||
|
disbursementGlEntries,
|
||||||
|
officeId, currencyCode, txnDateStr,
|
||||||
|
buildGlEntryComment("Disbursement", disbursementGlEntries, description));
|
||||||
|
// Link each CR entry to its savings/current account so ACCOUNT NO shows in the GL view.
|
||||||
|
if (disbGlTxnId != null) {
|
||||||
|
linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collection GL entry ───────────────────────────────────────────────
|
||||||
|
// Goal: DR SavingsControl(per account) / CR targetGL — one combined entry.
|
||||||
|
// Fineract withdrawals auto-create: DR SavingsControl / CR CashVault.
|
||||||
|
// We mark those auto-entries reversed (hidden from GL reports) and post
|
||||||
|
// the direct combined entry. Fallback: CashVault bridge if GL lookup fails.
|
||||||
|
if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) {
|
||||||
|
if (!collectionGlEntries.isEmpty()
|
||||||
|
&& collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) {
|
||||||
|
markTxnGlEntriesReversed(collectionGlEntries);
|
||||||
|
final List<GlEntry> snapC = new ArrayList<>(collectionGlEntries);
|
||||||
|
undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC));
|
||||||
|
final String collGlTxnId = postCombinedJournalEntry(
|
||||||
|
collectionGlEntries,
|
||||||
|
List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)),
|
||||||
|
officeId, currencyCode, txnDateStr,
|
||||||
|
buildGlEntryComment("Collection", collectionGlEntries, description));
|
||||||
|
// Link each DR entry to its savings account so ACCOUNT NO shows in the GL view.
|
||||||
|
if (collGlTxnId != null) {
|
||||||
|
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
|
||||||
|
if (cashVaultGlId != null) {
|
||||||
|
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
|
||||||
|
officeId, currencyCode, txnDateStr, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
|
||||||
|
// This restores account balances even when a later entry fails mid-processing.
|
||||||
|
for (int i = undoOps.size() - 1; i >= 0; i--) {
|
||||||
try {
|
try {
|
||||||
txnId = isCurrentAccount
|
undoOps.get(i).run();
|
||||||
? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description)
|
} catch (Exception undoEx) {
|
||||||
: executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description);
|
log.warn("Compensation undo op {} failed for disbursement request {}: {}",
|
||||||
} catch (IdempotentCommandProcessSucceedException idempotent) {
|
i, id, undoEx.getMessage());
|
||||||
txnId = extractResourceIdFromResponse(idempotent.getResponse());
|
|
||||||
} catch (Exception ex) {
|
|
||||||
throw apiError(422, "Failed to debit account " + acNo + ": "
|
|
||||||
+ (ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName()));
|
|
||||||
}
|
|
||||||
if (txnId != null) {
|
|
||||||
txnRefs.add(txnId);
|
|
||||||
if (!isCurrentAccount) {
|
|
||||||
withdrawalSavingsTxnRefs.add(txnId);
|
|
||||||
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
final String errMsg = ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName();
|
||||||
|
log.error("Approval failed for disbursement request {} by {}: {}", id, currentUser.getUsername(), errMsg);
|
||||||
// Disbursement offset: deposits auto-create DR Cash@Vault / CR Savings.
|
final String truncated = errMsg.length() > 1000 ? errMsg.substring(0, 1000) : errMsg;
|
||||||
// Cancel Cash@Vault side: DR sourceGL / CR Cash@Vault. Net: DR sourceGL / CR Savings.
|
final String failLog = buildActivityLog(
|
||||||
if (sourceGlId != null && !depositSavingsTxnRefs.isEmpty()) {
|
(String) record.get("activity_log"), "FAILED", currentUser.getUsername(), truncated);
|
||||||
final Long cashVaultGlId = findCashVaultGlId(depositSavingsTxnRefs, 2); // DEBIT side of deposit entry
|
try {
|
||||||
if (cashVaultGlId != null) {
|
jdbcTemplate.update(
|
||||||
postGlOffsetEntry(sourceGlId, cashVaultGlId, depositSavingsTotal,
|
"UPDATE m_clerk_disbursement_request SET status = 'FAILED',"
|
||||||
officeId, currencyCode, txnDateStr, description);
|
+ " error_message = ?, activity_log = ?, actioned_at = ?, manager_id = ?,"
|
||||||
}
|
+ " manager_name = ?, manager_username = ? WHERE id = ?",
|
||||||
}
|
truncated, failLog, Timestamp.from(Instant.now()),
|
||||||
|
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), id);
|
||||||
// Collection offset: withdrawals auto-create DR Savings / CR Cash@Vault.
|
} catch (Exception dbEx) {
|
||||||
// Cancel Cash@Vault side: DR Cash@Vault / CR targetGL. Net: DR Savings / CR targetGL.
|
log.error("Failed to persist FAILED status for disbursement request {}: {}", id, dbEx.getMessage());
|
||||||
if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) {
|
|
||||||
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); // CREDIT side of withdrawal entry
|
|
||||||
if (cashVaultGlId != null) {
|
|
||||||
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
|
|
||||||
officeId, currencyCode, txnDateStr, description);
|
|
||||||
}
|
}
|
||||||
|
// Return a sanitized message to the caller; full detail is in server logs and error_message field.
|
||||||
|
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final String txnRefsStr = String.join(",", txnRefs);
|
final String txnRefsStr = String.join(",", txnRefs);
|
||||||
final String updatedLog = buildActivityLog(
|
final String updatedLog = buildActivityLog(
|
||||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
|
||||||
|
jdbcTemplate.update("""
|
||||||
final int updated = jdbcTemplate.update("""
|
|
||||||
UPDATE m_clerk_disbursement_request SET
|
UPDATE m_clerk_disbursement_request SET
|
||||||
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
|
||||||
txn_refs = ?, activity_log = ?, actioned_at = ?
|
txn_refs = ?, activity_log = ?, actioned_at = ?
|
||||||
WHERE id = ? AND status = 'PENDING'
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||||
txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id
|
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();
|
final JsonObject res = new JsonObject();
|
||||||
res.addProperty("resourceId", id);
|
res.addProperty("resourceId", id);
|
||||||
@@ -488,6 +627,14 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
|
||||||
|
// Maker-checker: the person who submitted the request cannot also reject it.
|
||||||
|
final Long makerId = record.get("clerk_id") != null
|
||||||
|
? ((Number) record.get("clerk_id")).longValue() : null;
|
||||||
|
if (makerId != null && makerId.equals(currentUser.getId())) {
|
||||||
|
throw apiError(403,
|
||||||
|
"Maker-checker: you cannot reject a disbursement request you submitted yourself.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||||
}
|
}
|
||||||
@@ -519,12 +666,15 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void validateCheckerRole(final AppUser user) {
|
private boolean isCheckerRole(final AppUser user) {
|
||||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) return;
|
if (user.hasAnyPermission("ALL_FUNCTIONS")) return true;
|
||||||
final boolean isChecker = user.getRoles().stream()
|
return user.getRoles().stream()
|
||||||
.map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
.map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||||
if (!isChecker) {
|
}
|
||||||
|
|
||||||
|
private void validateCheckerRole(final AppUser user) {
|
||||||
|
if (!isCheckerRole(user)) {
|
||||||
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject disbursement requests.");
|
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject disbursement requests.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -550,7 +700,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
office_id, currency_code, credit_entries, payment_type_id, payment_type_name,
|
office_id, currency_code, credit_entries, payment_type_id, payment_type_name,
|
||||||
description, clerk_id, clerk_name, clerk_username,
|
description, clerk_id, clerk_name, clerk_username,
|
||||||
manager_id, manager_name, manager_username, rejection_reason,
|
manager_id, manager_name, manager_username, rejection_reason,
|
||||||
txn_refs, activity_log, submitted_at, actioned_at
|
txn_refs, error_message, activity_log, submitted_at, actioned_at
|
||||||
FROM m_clerk_disbursement_request WHERE id = ?
|
FROM m_clerk_disbursement_request WHERE id = ?
|
||||||
""", id);
|
""", id);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
@@ -559,6 +709,30 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return rows.get(0);
|
return rows.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void registerUndo(final List<Runnable> undoOps, final Long accountId,
|
||||||
|
final String txnId, final boolean isCurrentAccount) {
|
||||||
|
try {
|
||||||
|
final Long txnIdLong = Long.parseLong(txnId);
|
||||||
|
undoOps.add(() -> undoAccountTransaction(accountId, txnIdLong, isCurrentAccount));
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void undoAccountTransaction(final Long accountId, final Long transactionId,
|
||||||
|
final boolean isCurrentAccount) {
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
|
final CommandWrapper cmd = isCurrentAccount
|
||||||
|
? new CommandWrapperBuilder()
|
||||||
|
.undoCurrentAccountTransaction(accountId, transactionId)
|
||||||
|
.withJson(payload.toString()).build()
|
||||||
|
: new CommandWrapperBuilder()
|
||||||
|
.undoSavingsAccountTransaction(accountId, transactionId)
|
||||||
|
.withJson(payload.toString()).build();
|
||||||
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
|
commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
private String executeSavingsDeposit(
|
private String executeSavingsDeposit(
|
||||||
final Long savingsAccountId, final BigDecimal amount,
|
final Long savingsAccountId, final BigDecimal amount,
|
||||||
final Long paymentTypeId, final String txnDateStr, final String note) {
|
final Long paymentTypeId, final String txnDateStr, final String note) {
|
||||||
@@ -576,8 +750,6 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.savingsAccountDeposit(savingsAccountId)
|
.savingsAccountDeposit(savingsAccountId)
|
||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
// Clear the stored command source ID so each deposit is treated as a fresh command,
|
|
||||||
// not a retry of the previous deposit within the same HTTP request.
|
|
||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
@@ -750,4 +922,319 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
commandsSourceWritePlatformService.logCommandSource(cmd);
|
commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Combined GL entry helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Carries savings-control GL data for one account in a combined journal entry. */
|
||||||
|
private static final class GlEntry {
|
||||||
|
final Long glAccountId;
|
||||||
|
final BigDecimal amount;
|
||||||
|
final String accountRef; // savings account number shown in GL comments; null for source/target GL
|
||||||
|
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
||||||
|
final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries
|
||||||
|
|
||||||
|
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||||
|
final String accountRef, final String txnId) {
|
||||||
|
this(glAccountId, amount, accountRef, txnId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||||
|
final String accountRef, final String txnId, final Long savingsAccountId) {
|
||||||
|
this.glAccountId = glAccountId;
|
||||||
|
this.amount = amount;
|
||||||
|
this.accountRef = accountRef;
|
||||||
|
this.txnId = txnId;
|
||||||
|
this.savingsAccountId = savingsAccountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the savings-control GL account ID from a Fineract auto-created journal entry.
|
||||||
|
* typeEnum=1 (CREDIT) → SavingsControl for deposits (DR CashVault / CR SavingsControl).
|
||||||
|
* typeEnum=2 (DEBIT) → SavingsControl for withdrawals (DR SavingsControl / CR CashVault).
|
||||||
|
*/
|
||||||
|
private Long findAccountGlFromTxn(final String fullTxnId, final int typeEnum) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT account_id FROM acc_gl_journal_entry"
|
||||||
|
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
|
||||||
|
fullTxnId, typeEnum);
|
||||||
|
return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks all Fineract auto-created journal entries for these transactions as reversed=true.
|
||||||
|
* Reversed entries are excluded from GL reports — only our direct combined entry is visible.
|
||||||
|
* Compensation: unmarkTxnGlEntriesReversed() restores them if the combined entry fails.
|
||||||
|
*/
|
||||||
|
private void markTxnGlEntriesReversed(final List<GlEntry> entries) {
|
||||||
|
for (final GlEntry e : entries) {
|
||||||
|
if (e.txnId != null) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?",
|
||||||
|
e.txnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compensation for markTxnGlEntriesReversed — restores reversed=false so Fineract undo is clean. */
|
||||||
|
private void unmarkTxnGlEntriesReversed(final List<GlEntry> entries) {
|
||||||
|
for (final GlEntry e : entries) {
|
||||||
|
if (e.txnId != null) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ?",
|
||||||
|
e.txnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
|
||||||
|
* row to its savings account so the GL view shows ACCOUNT NO instead of "—".
|
||||||
|
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
|
||||||
|
*/
|
||||||
|
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
|
||||||
|
final List<GlEntry> entries, final int typeEnum) {
|
||||||
|
final List<Long> rowIds = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? ORDER BY id ASC",
|
||||||
|
Long.class, glTxnId, typeEnum);
|
||||||
|
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
|
||||||
|
final Long savingsAccId = entries.get(i).savingsAccountId;
|
||||||
|
if (savingsAccId != null) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?",
|
||||||
|
savingsAccId, rowIds.get(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the Savings Control GL account ID for this savings account (from acc_product_mapping). */
|
||||||
|
private Long getSavingsControlGlId(final Long savingsAccountId) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT apm.gl_account_id FROM m_savings_account sa "
|
||||||
|
+ "JOIN acc_product_mapping apm ON apm.product_id = sa.product_id "
|
||||||
|
+ "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1",
|
||||||
|
savingsAccountId);
|
||||||
|
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real CBS book transfer: directly credits a savings account without going through
|
||||||
|
* Fineract's cash deposit API. This avoids the CashVault intermediary GL entry entirely.
|
||||||
|
* Validates account is ACTIVE (status_enum=300), then inserts a savings transaction
|
||||||
|
* record and updates the balance. Running balance / cumulative balance fields are
|
||||||
|
* recalculated by Fineract's nightly updateRunningBalance batch job.
|
||||||
|
*/
|
||||||
|
private long directCreditSavingsAccount(final Long accountId, final BigDecimal amount,
|
||||||
|
final Long officeId, final String txnDate, final AppUser currentUser) {
|
||||||
|
final List<Map<String, Object>> accts = jdbcTemplate.queryForList(
|
||||||
|
"SELECT status_enum, account_balance_derived, total_deposits_derived FROM m_savings_account WHERE id = ?",
|
||||||
|
accountId);
|
||||||
|
if (accts.isEmpty()) {
|
||||||
|
throw new RuntimeException("Savings account " + accountId + " not found.");
|
||||||
|
}
|
||||||
|
final int statusEnum = ((Number) accts.get(0).get("status_enum")).intValue();
|
||||||
|
if (statusEnum != 300) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Savings account " + accountId + " is not active (status=" + statusEnum + ").");
|
||||||
|
}
|
||||||
|
final BigDecimal currentBalance = accts.get(0).get("account_balance_derived") != null
|
||||||
|
? (BigDecimal) accts.get(0).get("account_balance_derived") : BigDecimal.ZERO;
|
||||||
|
|
||||||
|
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
|
||||||
|
final OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||||
|
final Long userId = currentUser.getId();
|
||||||
|
|
||||||
|
final KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
final PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO m_savings_account_transaction "
|
||||||
|
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
|
||||||
|
+ "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
|
||||||
|
+ "running_balance_derived, is_lien_transaction, "
|
||||||
|
+ "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
|
||||||
|
+ "VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
|
||||||
|
new String[]{"id"});
|
||||||
|
ps.setLong(1, accountId);
|
||||||
|
ps.setLong(2, officeId);
|
||||||
|
ps.setDate(3, sqlDate);
|
||||||
|
ps.setBigDecimal(4, amount);
|
||||||
|
ps.setDate(5, sqlDate);
|
||||||
|
ps.setString(6, refNo);
|
||||||
|
ps.setBigDecimal(7, currentBalance.add(amount));
|
||||||
|
ps.setLong(8, userId);
|
||||||
|
ps.setLong(9, userId);
|
||||||
|
ps.setObject(10, now);
|
||||||
|
ps.setObject(11, now);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
final long txnId = Objects.requireNonNull(kh.getKey()).longValue();
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE m_savings_account SET "
|
||||||
|
+ "account_balance_derived = account_balance_derived + ?, "
|
||||||
|
+ "total_deposits_derived = total_deposits_derived + ? "
|
||||||
|
+ "WHERE id = ?",
|
||||||
|
amount, amount, accountId);
|
||||||
|
|
||||||
|
return txnId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compensation for directCreditSavingsAccount — marks transaction reversed and restores balance. */
|
||||||
|
private void directReverseSavingsCredit(final Long accountId, final BigDecimal amount, final long txnId) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE m_savings_account_transaction SET is_reversed = true WHERE id = ?", txnId);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE m_savings_account SET "
|
||||||
|
+ "account_balance_derived = account_balance_derived - ?, "
|
||||||
|
+ "total_deposits_derived = total_deposits_derived - ? "
|
||||||
|
+ "WHERE id = ?",
|
||||||
|
amount, amount, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the Savings Control GL account ID for this current account (from acc_product_mapping). */
|
||||||
|
private Long getCurrentControlGlId(final Long currentAccountId) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT apm.gl_account_id FROM m_current_account ca "
|
||||||
|
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
|
||||||
|
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1",
|
||||||
|
currentAccountId);
|
||||||
|
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real CBS book transfer: directly credits a current account without going through
|
||||||
|
* Fineract's cash deposit API. No CashVault entry is created anywhere in the DB.
|
||||||
|
* Validates account is ACTIVE (status_enum=300), inserts a current account transaction
|
||||||
|
* record, updates account_balance_derived and total_deposits_derived.
|
||||||
|
*/
|
||||||
|
private long directCreditCurrentAccount(final Long accountId, final BigDecimal amount,
|
||||||
|
final Long officeId, final String txnDate, final AppUser currentUser, final String note) {
|
||||||
|
final List<Map<String, Object>> accts = jdbcTemplate.queryForList(
|
||||||
|
"SELECT status_enum, account_balance_derived FROM m_current_account WHERE id = ?", accountId);
|
||||||
|
if (accts.isEmpty()) {
|
||||||
|
throw new RuntimeException("Current account " + accountId + " not found.");
|
||||||
|
}
|
||||||
|
final int statusEnum = ((Number) accts.get(0).get("status_enum")).intValue();
|
||||||
|
if (statusEnum != 300) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Current account " + accountId + " is not active (status=" + statusEnum + ").");
|
||||||
|
}
|
||||||
|
final BigDecimal currentBalance = accts.get(0).get("account_balance_derived") != null
|
||||||
|
? (BigDecimal) accts.get(0).get("account_balance_derived") : BigDecimal.ZERO;
|
||||||
|
|
||||||
|
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
|
||||||
|
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||||
|
final Long userId = currentUser.getId();
|
||||||
|
final String remarks = note != null && !note.isBlank()
|
||||||
|
? (note.length() > 490 ? note.substring(0, 490) : note) + " [GL book transfer]"
|
||||||
|
: "GL book transfer";
|
||||||
|
|
||||||
|
final KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
final PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO m_current_account_transaction "
|
||||||
|
+ "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, "
|
||||||
|
+ "is_reversed, is_reversal, is_manual, is_lien_transaction, "
|
||||||
|
+ "ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
|
||||||
|
+ "VALUES (?, ?, 1, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)",
|
||||||
|
new String[]{"id"});
|
||||||
|
ps.setLong(1, accountId);
|
||||||
|
ps.setLong(2, officeId);
|
||||||
|
ps.setDate(3, sqlDate);
|
||||||
|
ps.setBigDecimal(4, amount);
|
||||||
|
ps.setString(5, refNo);
|
||||||
|
ps.setDate(6, sqlDate);
|
||||||
|
ps.setBigDecimal(7, currentBalance.add(amount));
|
||||||
|
ps.setString(8, remarks);
|
||||||
|
ps.setLong(9, userId);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
final long txnId = Objects.requireNonNull(kh.getKey()).longValue();
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE m_current_account SET "
|
||||||
|
+ "account_balance_derived = account_balance_derived + ?, "
|
||||||
|
+ "total_deposits_derived = total_deposits_derived + ? "
|
||||||
|
+ "WHERE id = ?",
|
||||||
|
amount, amount, accountId);
|
||||||
|
|
||||||
|
return txnId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compensation for directCreditCurrentAccount — marks transaction reversed and restores balance. */
|
||||||
|
private void directReverseCurrentCredit(final Long accountId, final BigDecimal amount, final long txnId) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE m_current_account_transaction SET is_reversed = true WHERE id = ?", txnId);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE m_current_account SET "
|
||||||
|
+ "account_balance_derived = account_balance_derived - ?, "
|
||||||
|
+ "total_deposits_derived = total_deposits_derived - ? "
|
||||||
|
+ "WHERE id = ?",
|
||||||
|
amount, amount, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts ONE combined journal entry with multiple debits and credits via Fineract's
|
||||||
|
* createJournalEntry API. Each debit/credit carries the savings account reference
|
||||||
|
* in the overall comments so the GL entry identifies which accounts were affected.
|
||||||
|
*
|
||||||
|
* Collection: debits = [SavingsControl per account], credits = [targetGL]
|
||||||
|
* Disbursement: debits = [sourceGL], credits = [SavingsControl per account]
|
||||||
|
*/
|
||||||
|
private String postCombinedJournalEntry(
|
||||||
|
final List<GlEntry> debits, final List<GlEntry> credits,
|
||||||
|
final Long officeId, final String currencyCode,
|
||||||
|
final String txnDateStr, final String comments) {
|
||||||
|
|
||||||
|
final JsonArray debitArr = new JsonArray();
|
||||||
|
for (final GlEntry e : debits) {
|
||||||
|
final JsonObject d = new JsonObject();
|
||||||
|
d.addProperty("glAccountId", e.glAccountId);
|
||||||
|
d.addProperty("amount", e.amount);
|
||||||
|
debitArr.add(d);
|
||||||
|
}
|
||||||
|
final JsonArray creditArr = new JsonArray();
|
||||||
|
for (final GlEntry e : credits) {
|
||||||
|
final JsonObject c = new JsonObject();
|
||||||
|
c.addProperty("glAccountId", e.glAccountId);
|
||||||
|
c.addProperty("amount", e.amount);
|
||||||
|
creditArr.add(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
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", comments);
|
||||||
|
}
|
||||||
|
payload.add("debits", debitArr);
|
||||||
|
payload.add("credits", creditArr);
|
||||||
|
|
||||||
|
final CommandWrapper cmd = new CommandWrapperBuilder()
|
||||||
|
.createJournalEntry()
|
||||||
|
.withJson(payload.toString())
|
||||||
|
.build();
|
||||||
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
|
return result != null ? result.getTransactionId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the GL entry comments string listing all affected account numbers. */
|
||||||
|
private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
|
||||||
|
final String description) {
|
||||||
|
final StringBuilder sb = new StringBuilder(mode).append(":");
|
||||||
|
for (final GlEntry e : entries) {
|
||||||
|
if (e.accountRef != null) {
|
||||||
|
sb.append(" A/C ").append(e.accountRef).append(" ₹").append(e.amount).append(";");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (description != null && !description.isBlank()) {
|
||||||
|
sb.append(" ").append(description);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,4 +361,5 @@
|
|||||||
<include file="parts/0331_add_gl_columns_to_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"/>
|
<include file="parts/0332_create_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0333_rename_disbursed_to_withdrawn.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0333_rename_disbursed_to_withdrawn.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0334_clerk_disbursement_add_error_message.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?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="0334_clerk_disbursement_add_error_message" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_clerk_disbursement_request"/>
|
||||||
|
<not><columnExists tableName="m_clerk_disbursement_request" columnName="error_message"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_clerk_disbursement_request">
|
||||||
|
<column name="error_message" type="VARCHAR(1000)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user