feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same maker-checker path as a savings withdrawal: - the request carries gl_account_id, contra_gl_id, office_id and currency_code (new nullable columns, changeset 0600) instead of a savings account; - at submit it is validated: the paying GL must be a mapped cash vault GL and the slip's own GL must not be a cash GL; - at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry; - the cashier pays it against its token, and the denomination and voucher lines are recorded under the journal entry's id. KYC debit checks are skipped for GL requests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+158
-16
@@ -48,6 +48,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
|
||||||
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;
|
||||||
@@ -110,11 +111,20 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
||||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||||
|
// A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is
|
||||||
|
// paid from instead of a savings account; it is approved and disbursed exactly like any other slip.
|
||||||
|
final boolean isGl = "GL".equalsIgnoreCase(accountType);
|
||||||
|
final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root);
|
||||||
|
final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root);
|
||||||
|
final Long officeId = fromJsonHelper.extractLongNamed("officeId", root);
|
||||||
|
final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root);
|
||||||
|
|
||||||
if (accountNo == null || accountNo.isBlank()) {
|
if (accountNo == null || accountNo.isBlank()) {
|
||||||
throw apiError(400, "Account number is required");
|
throw apiError(400, "Account number is required");
|
||||||
}
|
}
|
||||||
if (savingsAccountId == null) {
|
if (isGl) {
|
||||||
|
validateGlWithdrawalAccounts(glAccountId, contraGlId);
|
||||||
|
} else if (savingsAccountId == null) {
|
||||||
throw apiError(400, "Savings account id is required for a withdrawal request");
|
throw apiError(400, "Savings account id is required for a withdrawal request");
|
||||||
}
|
}
|
||||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
@@ -124,7 +134,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
throw apiError(400, "Token number (TOK. NO.) is required");
|
throw apiError(400, "Token number (TOK. NO.) is required");
|
||||||
}
|
}
|
||||||
validateTokenUniqueToday(tokNo, null);
|
validateTokenUniqueToday(tokNo, null);
|
||||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
if (!isGl) {
|
||||||
|
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||||
|
}
|
||||||
|
|
||||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||||
|
|
||||||
@@ -132,8 +144,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
INSERT INTO m_clerk_withdrawal_request
|
INSERT INTO m_clerk_withdrawal_request
|
||||||
(account_no, account_type, savings_account_id, client_id, client_name, amount,
|
(account_no, account_type, savings_account_id, client_id, client_name, amount,
|
||||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
|
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
|
||||||
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
|
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
gl_account_id, contra_gl_id, office_id, currency_code)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
Long.class,
|
Long.class,
|
||||||
@@ -142,7 +155,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
txnDate != null ? Date.valueOf(txnDate) : null,
|
txnDate != null ? Date.valueOf(txnDate) : null,
|
||||||
"PENDING",
|
"PENDING",
|
||||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||||
denominationData, activityLog, Timestamp.from(Instant.now())
|
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||||
|
isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null,
|
||||||
|
isGl ? (currencyCode != null ? currencyCode : "INR") : null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Globally unique voucher number from the central m_voucher register
|
// Globally unique voucher number from the central m_voucher register
|
||||||
@@ -198,7 +213,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
|
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
|
||||||
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
||||||
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
||||||
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
|
+ " cashier_id, disbursed_by, disbursed_at, voucher_no,"
|
||||||
|
+ " gl_account_id, contra_gl_id, office_id, currency_code"
|
||||||
+ " FROM m_clerk_withdrawal_request " + where
|
+ " FROM m_clerk_withdrawal_request " + where
|
||||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray());
|
pageParams.toArray());
|
||||||
@@ -252,7 +268,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
payment_type_name, description, tok_no, txn_date, status,
|
payment_type_name, description, tok_no, txn_date, status,
|
||||||
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
|
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
|
||||||
submitted_at, actioned_at,
|
submitted_at, actioned_at,
|
||||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||||
|
gl_account_id, contra_gl_id, office_id, currency_code
|
||||||
FROM m_clerk_withdrawal_request
|
FROM m_clerk_withdrawal_request
|
||||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray()
|
pageParams.toArray()
|
||||||
@@ -276,7 +293,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||||
denomination_data, activity_log, submitted_at, actioned_at,
|
denomination_data, activity_log, submitted_at, actioned_at,
|
||||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||||
|
gl_account_id, contra_gl_id, office_id, currency_code
|
||||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||||
""", id);
|
""", id);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
@@ -295,7 +313,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||||
amount, payment_type_id, payment_type_name, description, tok_no, status,
|
amount, payment_type_id, payment_type_name, description, tok_no, status,
|
||||||
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
|
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no,
|
||||||
|
gl_account_id, contra_gl_id, office_id, currency_code
|
||||||
FROM m_clerk_withdrawal_request
|
FROM m_clerk_withdrawal_request
|
||||||
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
|
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
|
||||||
ORDER BY submitted_at DESC LIMIT 1
|
ORDER BY submitted_at DESC LIMIT 1
|
||||||
@@ -455,13 +474,16 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
|
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type"));
|
||||||
final Long savingsAccountId = record.get("savings_account_id") != null
|
final Long savingsAccountId = record.get("savings_account_id") != null
|
||||||
? ((Number) record.get("savings_account_id")).longValue() : null;
|
? ((Number) record.get("savings_account_id")).longValue() : null;
|
||||||
// Checked again on approval, not only on submission: a request can sit in the queue for days,
|
// Checked again on approval, not only on submission: a request can sit in the queue for days,
|
||||||
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
|
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
|
||||||
// below refuses a dormant account anyway, but that failure would surface to the manager as a
|
// below refuses a dormant account anyway, but that failure would surface to the manager as a
|
||||||
// rejected transaction after the approval was recorded; this says why, before anything moves.
|
// rejected transaction after the approval was recorded; this says why, before anything moves.
|
||||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
if (!isGlRequest) {
|
||||||
|
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||||
|
}
|
||||||
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
||||||
final Object ptId = record.get("payment_type_id");
|
final Object ptId = record.get("payment_type_id");
|
||||||
final Object txnDateObj = record.get("txn_date");
|
final Object txnDateObj = record.get("txn_date");
|
||||||
@@ -469,7 +491,11 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
final String description = (String) record.get("description");
|
final String description = (String) record.get("description");
|
||||||
|
|
||||||
String txnId = null;
|
String txnId = null;
|
||||||
if (savingsAccountId != null) {
|
if (isGlRequest) {
|
||||||
|
// Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal.
|
||||||
|
validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id")));
|
||||||
|
txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description);
|
||||||
|
} else if (savingsAccountId != null) {
|
||||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
|
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
|
||||||
txnId = isCurrentAccount
|
txnId = isCurrentAccount
|
||||||
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
|
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||||
@@ -477,14 +503,18 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Link voucher to the created savings txn and stamp voucher_no on it
|
// Link voucher to the created savings txn and stamp voucher_no on it
|
||||||
if (txnId != null) {
|
if (txnId != null && !isGlRequest) {
|
||||||
voucherService.linkSavingsTransaction(
|
voucherService.linkSavingsTransaction(
|
||||||
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
||||||
// debited and the till that pays it out is credited.
|
// debited and the till that pays it out is credited.
|
||||||
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
if (isGlRequest) {
|
||||||
|
addGlWithdrawalVoucherLines(record, amount);
|
||||||
|
} else {
|
||||||
|
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -689,8 +719,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
if (denomMap == null || denomMap.size() == 0) return;
|
if (denomMap == null || denomMap.size() == 0) return;
|
||||||
|
|
||||||
final Object txnIdObj = record.get("txn_id");
|
final Object txnIdObj = record.get("txn_id");
|
||||||
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
|
final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null;
|
||||||
if (txnId == null) return; // no savings txn linked — cannot record denomination
|
if (txnId == null) return; // no txn linked — cannot record denomination
|
||||||
|
|
||||||
final Object saObj = record.get("savings_account_id");
|
final Object saObj = record.get("savings_account_id");
|
||||||
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
|
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
|
||||||
@@ -793,6 +823,117 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
return rows.isEmpty() ? null : rows.get(0);
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Long toLong(final Object value) {
|
||||||
|
return value instanceof Number n ? n.longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one
|
||||||
|
* Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under
|
||||||
|
* the id of its own journal entry in the cashier's position and closing report.
|
||||||
|
*/
|
||||||
|
private Long parseTxnKey(final String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.valueOf(raw);
|
||||||
|
} catch (final NumberFormatException ignored) {
|
||||||
|
try {
|
||||||
|
final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw;
|
||||||
|
return Long.parseUnsignedLong(hex, 16);
|
||||||
|
} catch (final NumberFormatException ignored2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the
|
||||||
|
* GL it is paid from must be one of the branch's mapped cash accounts. */
|
||||||
|
private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) {
|
||||||
|
if (glAccountId == null || contraGlId == null) {
|
||||||
|
throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request");
|
||||||
|
}
|
||||||
|
final List<Long> cashGlIds = jdbcTemplate.queryForList(
|
||||||
|
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)",
|
||||||
|
Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue());
|
||||||
|
if (!cashGlIds.contains(contraGlId)) {
|
||||||
|
throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL.");
|
||||||
|
}
|
||||||
|
if (cashGlIds.contains(glAccountId)) {
|
||||||
|
throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is
|
||||||
|
* switched off for manual postings). Returns the journal entry's transaction id. */
|
||||||
|
private String executeGlWithdrawalJournal(final Map<?, ?> record, final BigDecimal amount,
|
||||||
|
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||||
|
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||||
|
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||||
|
|
||||||
|
final JsonObject debit = new JsonObject();
|
||||||
|
debit.addProperty("glAccountId", toLong(record.get("gl_account_id")));
|
||||||
|
debit.addProperty("amount", amount);
|
||||||
|
final JsonObject credit = new JsonObject();
|
||||||
|
credit.addProperty("glAccountId", toLong(record.get("contra_gl_id")));
|
||||||
|
credit.addProperty("amount", amount);
|
||||||
|
final JsonArray debits = new JsonArray();
|
||||||
|
debits.add(debit);
|
||||||
|
final JsonArray credits = new JsonArray();
|
||||||
|
credits.add(credit);
|
||||||
|
|
||||||
|
final Object office = record.get("office_id");
|
||||||
|
final Object currency = record.get("currency_code");
|
||||||
|
final JsonObject payload = new JsonObject();
|
||||||
|
payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L);
|
||||||
|
payload.addProperty("transactionDate", date);
|
||||||
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
|
payload.addProperty("locale", "en");
|
||||||
|
payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR");
|
||||||
|
if (note != null && !note.isBlank()) {
|
||||||
|
payload.addProperty("comments", note);
|
||||||
|
}
|
||||||
|
if (paymentTypeId != null) {
|
||||||
|
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||||
|
}
|
||||||
|
payload.add("credits", credits);
|
||||||
|
payload.add("debits", debits);
|
||||||
|
|
||||||
|
final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||||
|
// The request raised its own voucher at submission; the journal command must not raise a second.
|
||||||
|
final CommandProcessingResult result = voucherService
|
||||||
|
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||||
|
return result.getTransactionId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */
|
||||||
|
private void addGlWithdrawalVoucherLines(final Map<?, ?> record, final BigDecimal amount) {
|
||||||
|
final String voucherNo = (String) record.get("voucher_no");
|
||||||
|
if (voucherNo == null || voucherNo.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final Long glId = toLong(record.get("gl_account_id"));
|
||||||
|
final Long contraId = toLong(record.get("contra_gl_id"));
|
||||||
|
final Map<String, Object> gl = glAccountForVoucher(glId);
|
||||||
|
final Map<String, Object> contra = glAccountForVoucher(contraId);
|
||||||
|
voucherService.addLines(voucherNo, List.of(
|
||||||
|
VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount),
|
||||||
|
VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||||
|
if (glAccountId == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||||
|
.orElse(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||||
@@ -800,7 +941,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||||
denomination_data, activity_log, submitted_at, actioned_at,
|
denomination_data, activity_log, submitted_at, actioned_at,
|
||||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||||
|
gl_account_id, contra_gl_id, office_id, currency_code
|
||||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||||
""", id);
|
""", id);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
|
|||||||
@@ -174,4 +174,5 @@
|
|||||||
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0600_clerk_withdrawal_request_gl.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) goes through the same request, manager approval and
|
||||||
|
cashier disbursement as a savings withdrawal, so the request carries the GL account and the cash GL it is paid
|
||||||
|
from. All four columns are nullable: savings, current and deposit requests do not use them.
|
||||||
|
-->
|
||||||
|
<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="0600_clerk_withdrawal_request_add_gl_columns" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_clerk_withdrawal_request"/>
|
||||||
|
<not><columnExists tableName="m_clerk_withdrawal_request" columnName="gl_account_id"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_clerk_withdrawal_request">
|
||||||
|
<column name="gl_account_id" type="BIGINT"/>
|
||||||
|
<column name="contra_gl_id" type="BIGINT"/>
|
||||||
|
<column name="office_id" type="BIGINT"/>
|
||||||
|
<column name="currency_code" type="VARCHAR(10)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user