diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 813e80602..736480cb9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -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."); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java index a5ae75d14..8a51e1603 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendClaimRequestApiResource.java @@ -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 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> 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 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 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 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 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 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); + } } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java index 980a47ada..89e7a78bc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dividenddashboard/api/DividendDashboardApiResource.java @@ -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) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 0e33ce55b..dcc3ec932 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -510,7 +510,7 @@ @@ -527,4 +527,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0463_add_cash_disbursement_fields_to_dividend_claim_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0463_add_cash_disbursement_fields_to_dividend_claim_request.xml new file mode 100644 index 000000000..72e17a55e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0463_add_cash_disbursement_fields_to_dividend_claim_request.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0464_seed_dividend_claim_payment_type.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0464_seed_dividend_claim_payment_type.xml new file mode 100644 index 000000000..6137b9693 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0464_seed_dividend_claim_payment_type.xml @@ -0,0 +1,28 @@ + + + + + + + select count(*) from m_payment_type where code_name = 'DIVIDEND_CLAIM_SETTLEMENT' + + + + + + + + + + + +