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.
|
||||
*/
|
||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||
@@ -810,10 +812,16 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
sql.append(" AND id <> ?");
|
||||
params.add(excludeId);
|
||||
}
|
||||
final long count = Optional.ofNullable(
|
||||
final long ownCount = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||
).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,
|
||||
"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.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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),
|
||||
* 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
|
||||
* "Warehouse Receipt" account instead). Since the money never actually came from that account, an
|
||||
* immediate manual journal entry (Dr Dividend Payable / Cr whichever account was actually debited
|
||||
* -- read straight off the GL entry the native command just posted) replenishes it for that same
|
||||
* amount, so the net effect is exactly "Dividend Payable decreases, loan/savings changes, that
|
||||
* fund-source account is untouched". The cash leg has no account to post through, so it's a direct
|
||||
* manual entry only (Dr Dividend Payable / Cr Cash at Main Vault) -- real cash leaves the vault
|
||||
* into the member's hand.
|
||||
* "Warehouse Receipt" account instead). Since the money never actually came from that account,
|
||||
* that same DEBIT leg is rewritten in place to Dividend Payable (see
|
||||
* {@code redirectFundSourceDebitToDividendPayable}) rather than reversed with a second manual
|
||||
* entry pair, 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 --
|
||||
* instead of that fund-source account confusingly appearing twice. The cash leg has no account to
|
||||
* 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")
|
||||
@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",
|
||||
"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",
|
||||
"r.savings_apply_amount", "r.cash_apply_amount", "l.amount AS locked_amount", "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" };
|
||||
"r.savings_apply_amount", "r.cash_apply_amount", "r.tok_no", "r.cash_status", "l.amount AS locked_amount",
|
||||
"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} ──────────
|
||||
|
||||
@@ -142,7 +151,14 @@ public class DividendClaimRequestApiResource {
|
||||
final Long savingsAccountIdOverride = fromJsonHelper.extractLongNamed("savingsAccountId", root);
|
||||
final BigDecimal loanApplyAmount = parseAmount(fromJsonHelper.extractStringNamed("loanApplyAmount", 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) {
|
||||
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(
|
||||
"SELECT COUNT(*) FROM m_share_dividend_claim_request WHERE lock_id = ? AND status = 'PENDING'", Long.class, lockId);
|
||||
if (existingPending != null && existingPending > 0) {
|
||||
@@ -206,15 +229,39 @@ public class DividendClaimRequestApiResource {
|
||||
final Long newId = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_share_dividend_claim_request
|
||||
(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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
|
||||
cash_apply_amount, status, clerk_id, clerk_name, submitted_at, tok_no)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
|
||||
RETURNING id
|
||||
""", 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"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return BigDecimal.ZERO;
|
||||
@@ -312,8 +359,7 @@ public class DividendClaimRequestApiResource {
|
||||
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
|
||||
replenishFundSourceFromDividendPayable(id, currencyCode, loanApplyAmount, today, fundSourceAccountId);
|
||||
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId(), null);
|
||||
}
|
||||
|
||||
if (savingsApplyAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
@@ -323,12 +369,12 @@ public class DividendClaimRequestApiResource {
|
||||
payload.addProperty("transactionDate", today.toString());
|
||||
payload.addProperty("transactionAmount", savingsApplyAmount);
|
||||
payload.addProperty("note", note);
|
||||
payload.addProperty("paymentTypeId", resolveDividendClaimPaymentTypeId());
|
||||
final Long savingsAccountId = ((Number) claimSavingsAccountId).longValue();
|
||||
final CommandWrapper depositCommand = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult depositResult = commandsSourceWritePlatformService.logCommandSource(depositCommand);
|
||||
final Long fundSourceAccountId = resolveDebitAccountForSavingsTransaction(depositResult.getResourceId());
|
||||
replenishFundSourceFromDividendPayable(id, currencyCode, savingsApplyAmount, today, fundSourceAccountId);
|
||||
redirectFundSourceDebitToDividendPayable(null, depositResult.getResourceId());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
final String cashStatus = cashApplyAmount.compareTo(BigDecimal.ZERO) > 0 ? "AWAITING_DISBURSEMENT" : null;
|
||||
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'
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), id);
|
||||
""", currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), cashStatus, id);
|
||||
|
||||
final JsonObject response = new JsonObject();
|
||||
response.addProperty("resourceId", id);
|
||||
@@ -356,60 +404,261 @@ public class DividendClaimRequestApiResource {
|
||||
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
|
||||
* 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, these read the actual GL entry the
|
||||
* native command just posted for this transaction and return whichever account it debited.
|
||||
* Mirrors {@code ClerkWithdrawalRequestApiResource.getByToken} -- a cashier at Transaction
|
||||
* Entry types the clerk-supplied token number here to resolve the approved cash amount before
|
||||
* disbursing it, instead of the manager handing over cash directly at approval time.
|
||||
*/
|
||||
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);
|
||||
@GET
|
||||
@Path("/by-token/{token}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getByToken(@PathParam("token") final String token) {
|
||||
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) {
|
||||
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)
|
||||
.stream().findFirst().orElse(null);
|
||||
// ── POST /v1/clerk/dividend-claim-requests/{id}/disburse ── Cashier confirms cash hand-over ──
|
||||
|
||||
/**
|
||||
* Mirrors {@code ClerkWithdrawalRequestApiResource.disburse} -- the GL entry already posted at
|
||||
* 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
|
||||
* whichever account is that product's actual Fund Source -- it didn't really. This puts that
|
||||
* fund straight back, sourced from the Dividend Payable liability instead, so the net effect
|
||||
* is exactly "liability shrinks, the loan/savings account changes, that fund-source account is
|
||||
* 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.
|
||||
* Records the cashier's note/coin breakdown for a dividend cash disbursement into
|
||||
* {@code m_savings_txn_denomination} for vault-position reporting. There is no native savings
|
||||
* transaction backing this (the cash leg is a pure GL entry posted at approve time), so this
|
||||
* claim request's own id is used as the stable txn identifier for grouping.
|
||||
*/
|
||||
private void replenishFundSourceFromDividendPayable(final Long claimRequestId, final String currencyCode, final BigDecimal amount,
|
||||
final LocalDate entryDate, final Long fundSourceAccountId) {
|
||||
private void saveDisbursementDenomination(final Long claimRequestId, final Long clientId, final Long lockId,
|
||||
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
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (payableDividendsAccountId == null || fundSourceAccountId == null) {
|
||||
if (payableDividendsAccountId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
final java.sql.Date sqlEntryDate = java.sql.Date.valueOf(entryDate);
|
||||
final String transactionId = "DIVCLAIM" + claimRequestId + "-" + System.nanoTime();
|
||||
final String description = "Dividend claim #" + claimRequestId + " disbursed";
|
||||
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);
|
||||
if (loanTransactionId != null) {
|
||||
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
|
||||
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||
}
|
||||
if (savingsTransactionId != null) {
|
||||
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE savings_transaction_id = ? AND type_enum = ?",
|
||||
payableDividendsAccountId, savingsTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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.serialization.FromJsonHelper;
|
||||
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.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -67,7 +65,6 @@ public class DividendDashboardApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PlatformSecurityContext context;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
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
|
||||
* {@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
|
||||
* native and correct) and then replenishes the vault it assumed the cash came from, sourced
|
||||
* instead from the Dividend Payable liability -- identical mechanic to a claim's loan leg.
|
||||
* native and correct) and then redirects the debit leg it assumed the cash came from to the
|
||||
* Dividend Payable liability instead -- identical mechanic to a claim's loan leg.
|
||||
*/
|
||||
@POST
|
||||
@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") + ")");
|
||||
}
|
||||
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 JsonElement root = fromJsonHelper.parse(body);
|
||||
@@ -348,8 +344,7 @@ public class DividendDashboardApiResource {
|
||||
final CommandWrapper repaymentCommand = new CommandWrapperBuilder().loanRepaymentTransaction(loanId)
|
||||
.withJson(payload.toString()).build();
|
||||
final CommandProcessingResult repaymentResult = commandsSourceWritePlatformService.logCommandSource(repaymentCommand);
|
||||
final Long fundSourceAccountId = resolveDebitAccountForLoanTransaction(repaymentResult.getResourceId());
|
||||
replenishFundSourceFromDividendPayable(lockId, currencyCode, applyAmount, today, fundSourceAccountId);
|
||||
redirectFundSourceDebitToDividendPayable(repaymentResult.getResourceId());
|
||||
|
||||
final BigDecimal remainder = unclaimedAmount.subtract(applyAmount);
|
||||
if (remainder.compareTo(BigDecimal.ZERO) > 0) {
|
||||
@@ -370,53 +365,28 @@ public class DividendDashboardApiResource {
|
||||
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
|
||||
* this loan product's actual Fund Source -- it didn't really. This puts that fund straight
|
||||
* back, sourced from the Dividend Payable liability instead, so the net effect is exactly
|
||||
* "liability shrinks, loan reduces, that fund-source account is unaffected". Skipped (loan
|
||||
* still updated) if the bank hasn't mapped the Dividend Payable financial activity, or if the
|
||||
* repayment's own debit leg couldn't be found.
|
||||
* this loan 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 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,
|
||||
final LocalDate entryDate, final Long fundSourceAccountId) {
|
||||
private void redirectFundSourceDebitToDividendPayable(final Long loanTransactionId) {
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (payableDividendsAccountId == null || fundSourceAccountId == null) {
|
||||
if (payableDividendsAccountId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final AppUser currentUser = context.authenticatedUser();
|
||||
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);
|
||||
jdbcTemplate.update("UPDATE acc_gl_journal_entry SET account_id = ? WHERE loan_transaction_id = ? AND type_enum = ?",
|
||||
payableDividendsAccountId, loanTransactionId, JOURNAL_ENTRY_TYPE_DEBIT);
|
||||
}
|
||||
|
||||
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"/>
|
||||
<!-- 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
|
||||
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
|
||||
cosmetic only. Future migrations should start at 0463 to avoid a third collision. -->
|
||||
<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/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/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>
|
||||
|
||||
+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