feat(shares): route dividend cash claims through cashier token disbursement
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 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 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 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 Documentation build / build (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 7 of 10) (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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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 E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (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 Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (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 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 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 Documentation build / build (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 7 of 10) (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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (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 E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (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
Cash claims now mirror the existing clerk-withdrawal token flow: clerk submits with a token, manager approval posts the GL entry, and a cashier redeems the token at Transaction Entry to record physical hand-over -- no direct manager-to-member cash handover, and no double-disbursement. Also fixes GL accounting for the loan/savings legs: instead of reversing the native command's own debit with a second manual entry pair (leaving the fund-source account confusingly appearing twice), that debit leg is now rewritten in place to Dividend Payable. Floors cash claims to whole rupees since notes/coins can't represent paise, with backward-compatible tolerance for claims submitted before the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -797,7 +797,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the token number is unique for today's date among non-rejected requests.
|
* Ensures the token number is unique for today's date among non-rejected requests, checking
|
||||||
|
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
|
||||||
|
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
|
||||||
* {@code excludeId} is set during resubmit to skip the record being edited.
|
* {@code excludeId} is set during resubmit to skip the record being edited.
|
||||||
*/
|
*/
|
||||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||||
@@ -810,10 +812,16 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
sql.append(" AND id <> ?");
|
sql.append(" AND id <> ?");
|
||||||
params.add(excludeId);
|
params.add(excludeId);
|
||||||
}
|
}
|
||||||
final long count = Optional.ofNullable(
|
final long ownCount = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
if (count > 0) {
|
final long otherCount = Optional.ofNullable(
|
||||||
|
jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
||||||
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
||||||
|
Long.class, tokNo)
|
||||||
|
).orElse(0L);
|
||||||
|
if (ownCount + otherCount > 0) {
|
||||||
throw apiError(400,
|
throw apiError(400,
|
||||||
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||||
}
|
}
|
||||||
|
|||||||
+308
-59
@@ -37,9 +37,11 @@ import jakarta.ws.rs.WebApplicationException;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
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.math.RoundingMode;
|
||||||
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.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -69,13 +71,19 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
|
* repayment/deposit command (so loan schedules and savings ledgers stay fully native and correct),
|
||||||
* which debits whatever GL account is actually configured as that product's Fund Source -- not
|
* which debits whatever GL account is actually configured as that product's Fund Source -- not
|
||||||
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
|
* necessarily Cash at Main Vault (e.g. a Loan-Against-Warehouse-Receipt product debits its own
|
||||||
* "Warehouse Receipt" account instead). Since the money never actually came from that account, an
|
* "Warehouse Receipt" account instead). Since the money never actually came from that account,
|
||||||
* immediate manual journal entry (Dr Dividend Payable / Cr whichever account was actually debited
|
* that same DEBIT leg is rewritten in place to Dividend Payable (see
|
||||||
* -- read straight off the GL entry the native command just posted) replenishes it for that same
|
* {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual
|
||||||
* amount, so the net effect is exactly "Dividend Payable decreases, loan/savings changes, that
|
* entry pair, so the transaction ends up with exactly the entries a real accountant would expect
|
||||||
* fund-source account is untouched". The cash leg has no account to post through, so it's a direct
|
* -- Dividend Payable debited, Loan Portfolio/Income or Savings credited, nothing else touched --
|
||||||
* manual entry only (Dr Dividend Payable / Cr Cash at Main Vault) -- real cash leaves the vault
|
* instead of that fund-source account confusingly appearing twice. The cash leg has no account to
|
||||||
* into the member's hand.
|
* post through, so approval
|
||||||
|
* posts a direct manual entry (Dr Dividend Payable / Cr Cash at Main Vault) immediately -- but the
|
||||||
|
* physical hand-over is deferred: the claim's {@code cash_status} becomes
|
||||||
|
* {@code AWAITING_DISBURSEMENT} and the clerk-supplied token number ({@code tok_no}) must be
|
||||||
|
* redeemed by a cashier at Transaction Entry (see {@code getByToken}/{@code disburse} below) before
|
||||||
|
* the money is actually handed over, exactly mirroring {@code ClerkWithdrawalRequestApiResource}'s
|
||||||
|
* submit/approve/disburse token flow for ordinary savings withdrawals.
|
||||||
*/
|
*/
|
||||||
@Path("/v1/clerk/dividend-claim-requests")
|
@Path("/v1/clerk/dividend-claim-requests")
|
||||||
@Component
|
@Component
|
||||||
@@ -96,8 +104,9 @@ public class DividendClaimRequestApiResource {
|
|||||||
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
|
private static final String[] LIST_COLUMNS = { "r.id", "r.lock_id", "r.client_id", "c.display_name AS client_name",
|
||||||
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
|
"r.savings_account_id", "sav.account_no AS savings_account_no", "r.loan_id",
|
||||||
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
|
"(SELECT account_no FROM m_loan WHERE id = r.loan_id) AS loan_account_no", "r.loan_apply_amount",
|
||||||
"r.savings_apply_amount", "r.cash_apply_amount", "l.amount AS locked_amount", "l.release_due_date", "r.status", "r.clerk_id",
|
"r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount",
|
||||||
"r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason", "r.submitted_at", "r.actioned_at" };
|
"l.release_due_date", "r.status", "r.clerk_id", "r.clerk_name", "r.manager_id", "r.manager_name", "r.rejection_reason",
|
||||||
|
"r.submitted_at", "r.actioned_at" };
|
||||||
|
|
||||||
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
|
// ── GET /v1/clerk/dividend-claim-requests/overdue-loans/{clientId} ──────────
|
||||||
|
|
||||||
@@ -142,7 +151,14 @@ public class DividendClaimRequestApiResource {
|
|||||||
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
|
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
|
||||||
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
|
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", root));
|
||||||
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
|
final BigDecimal savingsApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("savingsApplyAmount", root));
|
||||||
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root));
|
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1),
|
||||||
|
// while a dividend share often works out to paise -- floor to the nearest rupee (never
|
||||||
|
// trusting the frontend to have already done so) so the denomination breakdown the
|
||||||
|
// cashier counts at disburse time can always match exactly; any leftover paise is simply
|
||||||
|
// not claimed here and stays part of the lock's balance for a future claim.
|
||||||
|
final BigDecimal cashApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("cashApplyAmount", root))
|
||||||
|
.setScale(0, RoundingMode.FLOOR);
|
||||||
|
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
||||||
if (lockId == null) {
|
if (lockId == null) {
|
||||||
throw apiError(400, "lockId is required");
|
throw apiError(400, "lockId is required");
|
||||||
}
|
}
|
||||||
@@ -197,6 +213,13 @@ public class DividendClaimRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
if (tokNo == null || tokNo.isBlank()) {
|
||||||
|
throw apiError(400, "Token number (TOK. NO.) is required when cashApplyAmount is greater than zero");
|
||||||
|
}
|
||||||
|
validateTokenUniqueToday(tokNo, null);
|
||||||
|
}
|
||||||
|
|
||||||
final Long existingPending = jdbcTemplate.queryForObject(
|
final Long existingPending = jdbcTemplate.queryForObject(
|
||||||
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
|
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
|
||||||
if (existingPending != null && existingPending > 0) {
|
if (existingPending != null && existingPending > 0) {
|
||||||
@@ -206,15 +229,39 @@ public class DividendClaimRequestApiResource {
|
|||||||
final Long newId = jdbcTemplate.queryForObject("""
|
final Long newId = jdbcTemplate.queryForObject("""
|
||||||
INSERT INTO m_share_dividend_claim_request
|
INSERT INTO m_share_dividend_claim_request
|
||||||
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
|
(lock_id, client_id, savings_account_id, requested_amount, loan_id, loan_apply_amount, savings_apply_amount,
|
||||||
cash_apply_amount, status, clerk_id, clerk_name, submitted_at)
|
cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
|
""", Long.class, lockId, clientId, resolvedSavingsAccountId, total, loanId, loanApplyAmount, savingsApplyAmount,
|
||||||
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()));
|
cashApplyAmount, currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()),
|
||||||
|
cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? tokNo : null);
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the token number is unique for today's date across both this table and
|
||||||
|
* {@code m_clerk_withdrawal_request} -- cashiers type either kind of token into the same
|
||||||
|
* Transaction Entry field, so the two pools must never collide.
|
||||||
|
*/
|
||||||
|
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||||
|
final StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
||||||
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'");
|
||||||
|
final List<Object> params = new ArrayList<>();
|
||||||
|
params.add(tokNo);
|
||||||
|
if (excludeId != null) {
|
||||||
|
sql.append(" AND id <> ?");
|
||||||
|
params.add(excludeId);
|
||||||
|
}
|
||||||
|
final Long ownCount = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray());
|
||||||
|
final Long otherCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request "
|
||||||
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'", Long.class, tokNo);
|
||||||
|
final long total = (ownCount == null ? 0 : ownCount) + (otherCount == null ? 0 : otherCount);
|
||||||
|
if (total > 0) {
|
||||||
|
throw apiError(400, "Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private BigDecimal parseAmount(final String value) {
|
private BigDecimal parseAmount(final String value) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
return BigDecimal.ZERO;
|
return BigDecimal.ZERO;
|
||||||
@@ -312,8 +359,7 @@ public class DividendClaimRequestApiResource {
|
|||||||
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
||||||
.withJson(payload.toString()).build();
|
.withJson(payload.toString()).build();
|
||||||
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||||
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
|
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null);
|
||||||
replenishFundSourceFromDividendPayable(id, currencyCode, loanApplyAmount, today, fundSourceAccountId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
@@ -323,12 +369,12 @@ public class DividendClaimRequestApiResource {
|
|||||||
payload.addProperty("transactionDate", today.toString());
|
payload.addProperty("transactionDate", today.toString());
|
||||||
payload.addProperty("transactionAmount", savingsApplyAmount);
|
payload.addProperty("transactionAmount", savingsApplyAmount);
|
||||||
payload.addProperty("note", note);
|
payload.addProperty("note", note);
|
||||||
|
payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId());
|
||||||
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
|
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
|
||||||
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||||
.withJson(payload.toString()).build();
|
.withJson(payload.toString()).build();
|
||||||
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
|
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
|
||||||
final Long fundSourceAccountId = resolveDebitAccountForSavingsTransaction(depositResult.getResourceId());
|
redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId());
|
||||||
replenishFundSourceFromDividendPayable(id, currencyCode, savingsApplyAmount, today, fundSourceAccountId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
if (cashApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
@@ -343,10 +389,12 @@ public class DividendClaimRequestApiResource {
|
|||||||
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
|
jdbcTemplate.update("UPDATE m_share_dividend_lock SET status = 'CLAIMED', last_modified_date = now() WHERE id = ?", lockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null;
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?
|
UPDATE m_share_dividend_claim_request SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = ?,
|
||||||
|
cash_status = ?
|
||||||
WHERE id = ? AND status = 'PENDING'
|
WHERE id = ? AND status = 'PENDING'
|
||||||
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), id);
|
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id);
|
||||||
|
|
||||||
final JsonObject response = new JsonObject();
|
final JsonObject response = new JsonObject();
|
||||||
response.addProperty("resourceId", id);
|
response.addProperty("resourceId", id);
|
||||||
@@ -356,60 +404,261 @@ public class DividendClaimRequestApiResource {
|
|||||||
return gson.toJson(response);
|
return gson.toJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/clerk/dividend-claim-requests/by-token/{token} ── Cashier token lookup ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which GL account a loan repayment or savings deposit's cash/fund-source leg actually debits
|
* Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction
|
||||||
* is a per-product (sometimes per-payment-type) configuration choice, not always literally
|
* Entry types the clerk-supplied token number here to resolve the approved cash amount before
|
||||||
* "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its own
|
* disbursing it, instead of the manager handing over cash directly at approval time.
|
||||||
* "Warehouse Receipt" account instead. Rather than assume, these read the actual GL entry the
|
|
||||||
* native command just posted for this transaction and return whichever account it debited.
|
|
||||||
*/
|
*/
|
||||||
private Long resolveDebitAccountForLoanTransaction(final Long loanTransactionId) {
|
@GET
|
||||||
return jdbcTemplate
|
@Path("/by-token/{token}")
|
||||||
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE loan_transaction_id = ? AND type_enum = ?", Long.class,
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
|
public String getByToken(@PathParam("token") final String token) {
|
||||||
.stream().findFirst().orElse(null);
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
|
SELECT r.id, r.client_id, c.display_name AS client_name, r.cash_apply_amount AS amount,
|
||||||
|
r.tok_no, r.status, r.cash_status, r.submitted_at, r.actioned_at
|
||||||
|
FROM m_share_dividend_claim_request r
|
||||||
|
JOIN m_client c ON c.id = r.client_id
|
||||||
|
WHERE r.tok_no = ? AND CAST(r.submitted_at AS DATE) = CURRENT_DATE
|
||||||
|
ORDER BY r.submitted_at DESC LIMIT 1
|
||||||
|
""", token);
|
||||||
|
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw apiError(404, "Token '" + token + "' not found for today. Please check the token number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> row = rows.get(0);
|
||||||
|
final String status = String.valueOf(row.get("status"));
|
||||||
|
final String cashStatus = row.get("cash_status") == null ? null : String.valueOf(row.get("cash_status"));
|
||||||
|
|
||||||
|
if ("PENDING".equals(status)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' is still PENDING — manager has not approved this claim yet.");
|
||||||
|
}
|
||||||
|
if ("REJECTED".equals(status)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' was REJECTED by the manager. This claim cannot be processed.");
|
||||||
|
}
|
||||||
|
if (!"APPROVED".equals(status)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' has unexpected status: " + status);
|
||||||
|
}
|
||||||
|
if ("DISBURSED".equals(cashStatus)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
|
||||||
|
}
|
||||||
|
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
|
||||||
|
throw apiError(422, "Token '" + token + "' has no cash portion awaiting disbursement.");
|
||||||
|
}
|
||||||
|
return gson.toJson(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long resolveDebitAccountForSavingsTransaction(final Long savingsTransactionId) {
|
// ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ──
|
||||||
return jdbcTemplate
|
|
||||||
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ?",
|
/**
|
||||||
Long.class, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
|
* Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at
|
||||||
.stream().findFirst().orElse(null);
|
* approve time, so this step is purely a physical-cash-handover record with double-disbursement
|
||||||
|
* protection ({@code WHERE cash_status = 'AWAITING_DISBURSEMENT'}), never a new transaction.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/disburse")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Transactional
|
||||||
|
public String disburse(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
|
final Map<String, Object> record = jdbcTemplate
|
||||||
|
.queryForList("SELECT id, lock_id, client_id, cash_apply_amount, cash_status FROM m_share_dividend_claim_request WHERE id = ?",
|
||||||
|
id)
|
||||||
|
.stream().findFirst().orElseThrow(() -> new NotFoundException("Dividend claim request not found: " + id));
|
||||||
|
|
||||||
|
final String cashStatus = record.get("cash_status") == null ? null : String.valueOf(record.get("cash_status"));
|
||||||
|
if (!"AWAITING_DISBURSEMENT".equals(cashStatus)) {
|
||||||
|
throw apiError(409, "Only claims AWAITING_DISBURSEMENT can be disbursed. Current cash status: " + cashStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
String denominationData = null;
|
||||||
|
BigDecimal transactionAmount = null;
|
||||||
|
if (body != null && !body.isBlank()) {
|
||||||
|
final JsonElement root = fromJsonHelper.parse(body);
|
||||||
|
denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||||
|
transactionAmount = parseAmount(fromJsonHelper.extractStringNamed("transactionAmount", root));
|
||||||
|
}
|
||||||
|
if (transactionAmount == null || transactionAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw apiError(400, "transactionAmount is required and must be greater than zero for disbursement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// cash_apply_amount is floored to whole rupees at submit time now (see submit() above),
|
||||||
|
// but a claim created before that fix may still carry paise -- floor here too so an older
|
||||||
|
// claim can still be disbursed in whole-rupee cash rather than being stuck forever.
|
||||||
|
final BigDecimal cashApplyAmount = (BigDecimal) record.get("cash_apply_amount");
|
||||||
|
final BigDecimal disbursableAmount = cashApplyAmount.setScale(0, RoundingMode.FLOOR);
|
||||||
|
if (disbursableAmount.compareTo(transactionAmount) != 0) {
|
||||||
|
throw apiError(400, "Transaction amount " + transactionAmount + " does not match the claim's disbursable cash amount of "
|
||||||
|
+ disbursableAmount + " (cash can only be handed over in whole rupees).");
|
||||||
|
}
|
||||||
|
|
||||||
|
final long denomTotal = denominationTotal(denominationData);
|
||||||
|
if (denomTotal > 0 && BigDecimal.valueOf(denomTotal).compareTo(transactionAmount) != 0) {
|
||||||
|
throw apiError(400,
|
||||||
|
"Cash denomination total " + denomTotal + " does not match disbursement amount " + transactionAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int updated = jdbcTemplate.update("""
|
||||||
|
UPDATE m_share_dividend_claim_request SET
|
||||||
|
cash_status = 'DISBURSED', cashier_id = ?, disbursed_by = ?, disbursement_denomination_data = ?, disbursed_at = ?
|
||||||
|
WHERE id = ? AND cash_status = 'AWAITING_DISBURSEMENT'
|
||||||
|
""", currentUser.getId(), currentUser.getDisplayName(), denominationData, Timestamp.from(Instant.now()), id);
|
||||||
|
|
||||||
|
if (updated == 0) {
|
||||||
|
throw apiError(409, "This dividend cash claim has already been withdrawn by another cashier. Double disbursement prevented.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denominationData != null && !denominationData.isBlank()) {
|
||||||
|
final Long clientId = ((Number) record.get("client_id")).longValue();
|
||||||
|
final Long lockId = ((Number) record.get("lock_id")).longValue();
|
||||||
|
saveDisbursementDenomination(id, clientId, lockId, denominationData, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sums a {"note:500": 2, "coin:1": 10} breakdown; 0 when absent or empty. */
|
||||||
|
private long denominationTotal(final String denominationData) {
|
||||||
|
if (denominationData == null || denominationData.isBlank()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final JsonObject denomMap;
|
||||||
|
try {
|
||||||
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
throw apiError(400, "denominationData is not valid JSON");
|
||||||
|
}
|
||||||
|
if (denomMap == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
long total = 0;
|
||||||
|
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
||||||
|
final String[] parts = entry.getKey().split(":", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final int value;
|
||||||
|
final int count;
|
||||||
|
try {
|
||||||
|
value = Integer.parseInt(parts[1]);
|
||||||
|
count = entry.getValue().getAsInt();
|
||||||
|
} catch (final Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
total += (long) value * count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The loan repayment / savings deposit just posted above assumed the money came out of
|
* Records the cashier's note/coin breakdown for a dividend cash disbursement into
|
||||||
* whichever account is that product's actual Fund Source -- it didn't really. This puts that
|
* {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings
|
||||||
* fund straight back, sourced from the Dividend Payable liability instead, so the net effect
|
* transaction backing this (the cash leg is a pure GL entry posted at approve time), so this
|
||||||
* is exactly "liability shrinks, the loan/savings account changes, that fund-source account is
|
* claim request's own id is used as the stable txn identifier for grouping.
|
||||||
* unaffected". Skipped (loan/savings still updated) if the bank hasn't mapped the Dividend
|
|
||||||
* Payable financial activity, or if the command's own debit leg couldn't be found.
|
|
||||||
*/
|
*/
|
||||||
private void replenishFundSourceFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
|
private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId,
|
||||||
final LocalDate entryDate, final Long fundSourceAccountId) {
|
final String denominationData, final AppUser cashier) {
|
||||||
|
final JsonObject denomMap;
|
||||||
|
try {
|
||||||
|
denomMap = gson.fromJson(denominationData, JsonObject.class);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (denomMap == null || denomMap.size() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
|
||||||
|
final int count;
|
||||||
|
try {
|
||||||
|
count = entry.getValue().getAsInt();
|
||||||
|
} catch (final Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (count <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String[] parts = entry.getKey().split(":", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String kind = parts[0];
|
||||||
|
final int value;
|
||||||
|
try {
|
||||||
|
value = Integer.parseInt(parts[1]);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Long> ids = jdbcTemplate.queryForList(
|
||||||
|
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ? AND (deleted IS NULL OR deleted = false) LIMIT 1",
|
||||||
|
Long.class, value, kind);
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Long denomId = ids.get(0);
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_txn_denomination
|
||||||
|
(txn_id, savings_account_id, account_no, client_id, denomination_id,
|
||||||
|
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
|
||||||
|
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'WITHDRAWAL', ?, ?)
|
||||||
|
""", claimRequestId, "DIVIDEND-" + lockId, clientId, denomId, value, count,
|
||||||
|
BigDecimal.valueOf((long) value * count), cashier.getId(), cashier.getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fineract's savings deposit command mandates paymentTypeId even for an internal posting with
|
||||||
|
* no real customer payment instrument -- resolves the system-seeded "Dividend Claim
|
||||||
|
* Settlement" payment type (see migration 0464) rather than hardcoding a numeric id, which
|
||||||
|
* would break on any tenant seeded differently.
|
||||||
|
*/
|
||||||
|
private Long resolveDividendClaimPaymentTypeId() {
|
||||||
|
final Long paymentTypeId = jdbcTemplate
|
||||||
|
.queryForList("SELECT id FROM m_payment_type WHERE code_name = 'DIVIDEND_CLAIM_SETTLEMENT'", Long.class).stream()
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
if (paymentTypeId == null) {
|
||||||
|
throw apiError(500, "Payment type 'DIVIDEND_CLAIM_SETTLEMENT' is not seeded -- run the latest database migrations.");
|
||||||
|
}
|
||||||
|
return paymentTypeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loan repayment / savings deposit command just posted its own native GL entries assuming
|
||||||
|
* the money came out of whichever account is that product's actual Fund Source (not always
|
||||||
|
* literally "Cash at Main Vault" -- e.g. a Loan-Against-Warehouse-Receipt product debits its
|
||||||
|
* own "Warehouse Receipt" account instead) -- it didn't really. Rather than reverse that with
|
||||||
|
* a second manual entry pair (which would leave that fund-source account appearing twice, once
|
||||||
|
* debited then immediately credited back -- a confusing "wash" no real accountant would post),
|
||||||
|
* this rewrites that same DEBIT leg's account in place to Dividend Payable, so the transaction
|
||||||
|
* ends up with exactly the entries a real accountant would expect: Dividend Payable debited,
|
||||||
|
* Loan Portfolio/Income or Savings credited, nothing else touched. Skipped (loan/savings still
|
||||||
|
* updated) if the bank hasn't mapped the Dividend Payable financial activity. Exactly one of
|
||||||
|
* the two ids is non-null per call.
|
||||||
|
*/
|
||||||
|
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId, final Long savingsTransactionId) {
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
.stream().findFirst().orElse(null);
|
.stream().findFirst().orElse(null);
|
||||||
if (payableDividendsAccountId == null || fundSourceAccountId == null) {
|
if (payableDividendsAccountId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (loanTransactionId != null) {
|
||||||
final AppUser currentUser = context.authenticatedUser();
|
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
|
||||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||||
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
|
}
|
||||||
final String description = "Dividend claim #" + claimRequestId + " disbursed";
|
if (savingsTransactionId != null) {
|
||||||
final String insertSql = """
|
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?",
|
||||||
INSERT INTO acc_gl_journal_entry
|
payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
}
|
||||||
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
|
|
||||||
submitted_on_date, transaction_date)
|
|
||||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
|
||||||
""";
|
|
||||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
|
||||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
|
||||||
this.jdbcTemplate.update(insertSql, fundSourceAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
|
||||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+16
-46
@@ -46,8 +46,6 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -67,7 +65,6 @@ public class DividendDashboardApiResource {
|
|||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||||
|
|
||||||
@@ -294,8 +291,8 @@ public class DividendDashboardApiResource {
|
|||||||
* pure "Dividend Payable" GL liability until claimed -- see
|
* pure "Dividend Payable" GL liability until claimed -- see
|
||||||
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
|
* {@code DividendClaimRequestApiResource}), there's no hold to release or account-transfer to
|
||||||
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
|
* make: this posts a native loan repayment (so the loan's own schedule/state stays fully
|
||||||
* native and correct) and then replenishes the vault it assumed the cash came from, sourced
|
* native and correct) and then redirects the debit leg it assumed the cash came from to the
|
||||||
* instead from the Dividend Payable liability -- identical mechanic to a claim's loan leg.
|
* Dividend Payable liability instead -- identical mechanic to a claim's loan leg.
|
||||||
*/
|
*/
|
||||||
@POST
|
@POST
|
||||||
@Path("/{lockId}/apply-to-loan")
|
@Path("/{lockId}/apply-to-loan")
|
||||||
@@ -310,7 +307,6 @@ public class DividendDashboardApiResource {
|
|||||||
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
|
throw apiError(409, "This dividend is not currently UNCLAIMED (status: " + lock.get("status") + ")");
|
||||||
}
|
}
|
||||||
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
final Long clientId = ((Number) lock.get("client_id")).longValue();
|
||||||
final String currencyCode = (String) lock.get("currency_code");
|
|
||||||
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
final BigDecimal unclaimedAmount = (BigDecimal) lock.get("amount");
|
||||||
|
|
||||||
final JsonElement root = fromJsonHelper.parse(body);
|
final JsonElement root = fromJsonHelper.parse(body);
|
||||||
@@ -348,8 +344,7 @@ public class DividendDashboardApiResource {
|
|||||||
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
||||||
.withJson(payload.toString()).build();
|
.withJson(payload.toString()).build();
|
||||||
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||||
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
|
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
|
||||||
replenishFundSourceFromDividendPayable(lockId, currencyCode, applyAmount, today, fundSourceAccountId);
|
|
||||||
|
|
||||||
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
|
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
|
||||||
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
@@ -370,53 +365,28 @@ public class DividendDashboardApiResource {
|
|||||||
return gson.toJson(response);
|
return gson.toJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Which GL account a loan repayment's cash/fund-source leg actually debits is a per-product
|
|
||||||
* (sometimes per-payment-type) configuration choice, not always literally "Cash at Main Vault"
|
|
||||||
* -- e.g. a Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account
|
|
||||||
* instead. Rather than assume, this reads the actual GL entry the native repayment command
|
|
||||||
* just posted for this transaction and returns whichever account it debited.
|
|
||||||
*/
|
|
||||||
private Long resolveDebitAccountForLoanTransaction(final Long loanTransactionId) {
|
|
||||||
return jdbcTemplate
|
|
||||||
.queryForList("SELECT account_id FROM acc_gl_journal_entry WHERE loan_transaction_id = ? AND type_enum = ?", Long.class,
|
|
||||||
loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The loan repayment just posted above assumed the money came out of whichever account is
|
* The loan repayment just posted above assumed the money came out of whichever account is
|
||||||
* this loan product's actual Fund Source -- it didn't really. This puts that fund straight
|
* this loan product's actual Fund Source (not always literally "Cash at Main Vault" -- e.g. a
|
||||||
* back, sourced from the Dividend Payable liability instead, so the net effect is exactly
|
* Loan-Against-Warehouse-Receipt product debits its own "Warehouse Receipt" account instead)
|
||||||
* "liability shrinks, loan reduces, that fund-source account is unaffected". Skipped (loan
|
* -- it didn't really. Rather than reverse that with a second manual entry pair (which would
|
||||||
* still updated) if the bank hasn't mapped the Dividend Payable financial activity, or if the
|
* leave that fund-source account appearing twice, once debited then immediately credited back
|
||||||
* repayment's own debit leg couldn't be found.
|
* -- a confusing "wash" no real accountant would post), this rewrites that same DEBIT leg's
|
||||||
|
* account in place to Dividend Payable, so the transaction ends up with exactly the entries a
|
||||||
|
* real accountant would expect: Dividend Payable debited, Loan Portfolio/Income credited,
|
||||||
|
* nothing else touched. Skipped (loan still updated) if the bank hasn't mapped the Dividend
|
||||||
|
* Payable financial activity.
|
||||||
*/
|
*/
|
||||||
private void replenishFundSourceFromDividendPayable(final Long lockId, final String currencyCode, final BigDecimal amount,
|
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
|
||||||
final LocalDate entryDate, final Long fundSourceAccountId) {
|
|
||||||
final Long payableDividendsAccountId = jdbcTemplate
|
final Long payableDividendsAccountId = jdbcTemplate
|
||||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||||
.stream().findFirst().orElse(null);
|
.stream().findFirst().orElse(null);
|
||||||
if (payableDividendsAccountId == null || fundSourceAccountId == null) {
|
if (payableDividendsAccountId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
|
||||||
final AppUser currentUser = context.authenticatedUser();
|
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
|
||||||
final String transactionId = "DIVAPPLYLOAN" + lockId + "-" + System.nanoTime();
|
|
||||||
final String description = "Unclaimed dividend lock #" + lockId + " applied to loan";
|
|
||||||
final String insertSql = """
|
|
||||||
INSERT INTO acc_gl_journal_entry
|
|
||||||
(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum, amount,
|
|
||||||
description, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc, last_modified_on_utc,
|
|
||||||
submitted_on_date, transaction_date)
|
|
||||||
VALUES (?, 1, ?, ?, false, true, ?, ?, ?, ?, ?, ?, now(), now(), now(), now(), ?, ?)
|
|
||||||
""";
|
|
||||||
this.jdbcTemplate.update(insertSql, payableDividendsAccountId, currencyCode, transactionId, sqlEntryDate, 2, amount, description,
|
|
||||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
|
||||||
this.jdbcTemplate.update(insertSql, fundSourceAccountId, currencyCode, transactionId, sqlEntryDate, 1, amount, description,
|
|
||||||
currentUser.getId(), currentUser.getId(), sqlEntryDate, sqlEntryDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||||
|
|||||||
@@ -510,7 +510,7 @@
|
|||||||
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
||||||
<!-- Two branches independently used 0456-0462 for unrelated migrations (share dividend vs
|
<!-- Two branches independently used 0456-0462 for unrelated migrations (share dividend vs
|
||||||
KYC/AML). Both sets are already applied against real local databases under these exact
|
KYC/AML). Both sets are already applied against real local databases under these exact
|
||||||
filenames, so neither is renumbered here -- Liquibase identifies a changeSet by its
|
filenames, so neither is renumbered here — Liquibase identifies a changeSet by its
|
||||||
(id, author, filename) triplet, not by the numeric prefix, so the duplicate numbering is
|
(id, author, filename) triplet, not by the numeric prefix, so the duplicate numbering is
|
||||||
cosmetic only. Future migrations should start at 0463 to avoid a third collision. -->
|
cosmetic only. Future migrations should start at 0463 to avoid a third collision. -->
|
||||||
<include file="parts/0456_create_share_dividend_lock.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0456_create_share_dividend_lock.xml" relativeToChangelogFile="true"/>
|
||||||
@@ -527,4 +527,6 @@
|
|||||||
<include file="parts/0460_create_aml_recurring_review.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0460_create_aml_recurring_review.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0461_add_pan_declaration_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0461_add_pan_declaration_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0463_add_cash_disbursement_fields_to_dividend_claim_request.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0464_seed_dividend_claim_payment_type.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
-->
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<!-- The cash leg of a dividend claim now waits for a cashier to redeem a token number at
|
||||||
|
Transaction Entry (mirroring m_clerk_withdrawal_request) instead of being handed over the
|
||||||
|
instant a manager approves the claim. cash_status tracks only that physical hand-over
|
||||||
|
lifecycle, independently of the claim's own maker-checker status column, since a single
|
||||||
|
claim can also carry loan/savings legs that are unaffected by this. -->
|
||||||
|
<changeSet id="0463_add_cash_disbursement_fields_to_dividend_claim_request" author="CBS">
|
||||||
|
<addColumn tableName="m_share_dividend_claim_request">
|
||||||
|
<column name="tok_no" type="VARCHAR(50)"/>
|
||||||
|
<column name="cash_status" type="VARCHAR(30)"/>
|
||||||
|
<column name="cashier_id" type="BIGINT"/>
|
||||||
|
<column name="disbursed_by" type="VARCHAR(150)"/>
|
||||||
|
<column name="disbursed_at" type="TIMESTAMP"/>
|
||||||
|
<column name="disbursement_denomination_data" type="TEXT"/>
|
||||||
|
</addColumn>
|
||||||
|
<createIndex tableName="m_share_dividend_claim_request" indexName="idx_dcr_tok_no">
|
||||||
|
<column name="tok_no"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- Fineract's SavingsAccountTransactionDataValidator mandates paymentTypeId (notNull, no
|
||||||
|
exceptions) for a savings deposit command — even one posted internally by
|
||||||
|
DividendClaimRequestApiResource.approve() for the savings-credit leg of a dividend claim,
|
||||||
|
which has no real customer-facing payment instrument. Mirrors 0440's Share Redemption
|
||||||
|
Transfer payment type exactly: a fixed code_name to look up by (never a hardcoded numeric
|
||||||
|
id, which would break on any tenant seeded differently), left for an accountant to map to
|
||||||
|
a GL account via "Map Payment Types to Specific Funds" if desired. runOnChange lets this
|
||||||
|
file's wording/flag tweaks safely re-validate where it already ran. -->
|
||||||
|
<changeSet author="CBS" id="0464-1-seed-dividend-claim-payment-type" runOnChange="true">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">select count(*) from m_payment_type where code_name = 'DIVIDEND_CLAIM_SETTLEMENT'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_payment_type">
|
||||||
|
<column name="value" value="Dividend Claim Settlement"/>
|
||||||
|
<column name="description" value="Internal book-transfer settlement of a dividend claim's loan/savings leg -- no real customer payment instrument"/>
|
||||||
|
<column name="is_cash_payment" valueBoolean="false"/>
|
||||||
|
<column name="order_position" valueNumeric="0"/>
|
||||||
|
<column name="code_name" value="DIVIDEND_CLAIM_SETTLEMENT"/>
|
||||||
|
<column name="is_system_defined" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user