fix(shares,savings): remove product-config voucher auto-resolve, fix charge/currency edit bugs, gate withdrawal on settlement
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract 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 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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract 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

- ShareAdvanceCollectionApiResource: drop the hardcoded/auto-resolved Share Suspense GL lookup for
  by-voucher search; GL account and voucher are now both required, manually-selected inputs. apply()
  now accepts a separately-verified entry-fee receipt (chargeCollectionId) so a fee collected into a
  different GL than the share value is never swept into the Sundry leg or double-posted to income.
- ClerkShareWithdrawalRequestApiResource: hard-block approval when collection_applied is false, so
  shares nobody has actually paid for can't be redeemed off an Active-but-unsettled account.
- ProductPendingEditFieldMapper / ShareProductDataSerializer: fix maker-checker edits on share
  products silently dropping charges (chargesSelected vs charges field-name mismatch) and NPEing on
  approval when the replay JSON carries no currencyCode.
- SavingsAccount: initialize the clearing-hold field to zero so new savings submissions don't violate
  the NOT NULL constraint on total_savings_amount_on_hold_clearing.
- CashReportApiResource: fix voucher number resolution for GL-type ledger entries.
- application.properties: revert local tenant DB password default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 20:02:50 +05:30
co-authored by Claude Sonnet 5
parent 29677e610c
commit 8ecab08d57
8 changed files with 238 additions and 46 deletions
@@ -903,10 +903,23 @@ public class CashReportApiResource {
// the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/ // the real, formatted voucher number (e.g. "CR/001/2908/0001") stamped on the deposit/
// withdrawal request at approval time -- std.txn_id on its own is only the internal savings // withdrawal request at approval time -- std.txn_id on its own is only the internal savings
// transaction id (e.g. "4"), never what was actually printed on the voucher. // transaction id (e.g. "4"), never what was actually printed on the voucher.
//
// A GL-type deposit's own txn_id is a hex string (e.g. "a29ff54dc88f", from a journal-entry
// reference) rather than a plain decimal savings-transaction id, but std.txn_id is always
// stored as the DECIMAL parse of whatever id it was given -- so for a GL row it holds the
// hex string's numeric value (178807899015311), not the hex string itself, and a plain
// string-equality join against cdr.txn_id can never match it. The second half of each OR
// below re-derives that same decimal value from the hex string to match it -- guarded to
// only run on strings that are actually hex (so an ordinary decimal txn_id, which never
// contains a-f, is never sent through the hex cast at all).
final String baseGroupSql = "FROM m_savings_txn_denomination std " final String baseGroupSql = "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_client mc ON mc.id = std.client_id " + "LEFT JOIN m_client mc ON mc.id = std.client_id "
+ "LEFT JOIN m_cashier_deposit_request cdr ON cdr.txn_id = CAST(std.txn_id AS VARCHAR) " + "LEFT JOIN m_cashier_deposit_request cdr ON cdr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cdr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cdr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cdr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) "
+ "LEFT JOIN m_clerk_withdrawal_request cwr ON cwr.txn_id = CAST(std.txn_id AS VARCHAR) " + "LEFT JOIN m_clerk_withdrawal_request cwr ON cwr.txn_id = CAST(std.txn_id AS VARCHAR) "
+ " OR (cwr.txn_id ~ '^[0-9a-fA-F]{1,16}$' AND cwr.txn_id ~ '[a-fA-F]' "
+ " AND ('x' || lpad(cwr.txn_id, 16, '0'))::bit(64)::bigint = std.txn_id) "
+ "WHERE std.txn_date = CAST(? AS DATE) " + "WHERE std.txn_date = CAST(? AS DATE) "
+ officerFilter + officerFilter
+ " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no "; + " GROUP BY std.txn_id, std.account_no, mc.display_name, std.cif_no ";
@@ -104,8 +104,6 @@ public class ShareAdvanceCollectionApiResource {
// ── m_share_account.status_enum ─────────────────────────────────────── // ── m_share_account.status_enum ───────────────────────────────────────
private static final int STATUS_ACTIVE = 300; private static final int STATUS_ACTIVE = 300;
// ── GL account the cashier deposits share money into ──────────────────
private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001";
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@@ -213,17 +211,18 @@ public class ShareAdvanceCollectionApiResource {
@GET @GET
@Path("/by-voucher") @Path("/by-voucher")
public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo, public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
@QueryParam("clientId") final Long clientIdHint) { @QueryParam("clientId") final Long clientIdHint, @QueryParam("glAccountNo") final String glAccountNoOverride) {
if (voucherNo == null || voucherNo.isBlank()) { if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required", throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
"A voucher or receipt number is required."); "A voucher or receipt number is required.");
} }
return findByVoucher(voucherNo, clientIdHint); return findByVoucher(voucherNo, clientIdHint, glAccountNoOverride);
} }
@GET @GET
@Path("/by-voucher/{voucherNo}") @Path("/by-voucher/{voucherNo}")
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) { public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint,
@QueryParam("glAccountNo") final String glAccountNoOverride) {
this.context.authenticatedUser(); this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves. // Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
@@ -234,15 +233,19 @@ public class ShareAdvanceCollectionApiResource {
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo); + " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
if (rows.isEmpty()) { if (rows.isEmpty()) {
// Cash is normally taken through the cashier's Transaction Entry deposit into the Share // Cash is normally taken through the cashier's Transaction Entry deposit into the GL
// Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a // account the counter deposited it to, which lives in m_cashier_deposit_request. Adopt
// collection so the member's voucher resolves here. // that approved deposit as a collection so the member's voucher resolves here. Which GL
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint); // account to look in is never guessed from product configuration — the counter selects it
// by hand every time, since a product mapping can silently go missing or point at the
// wrong account and a plain voucher lookup should never depend on that being right.
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint, glAccountNoOverride);
if (adopted != null) { if (adopted != null) {
return Response.ok(gson.toJson(adopted)).build(); return Response.ok(gson.toJson(adopted)).build();
} }
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher " throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
+ voucherNo + ". Check the number, or confirm the deposit has been approved by a manager."); + voucherNo + " in GL account " + glAccountNoOverride + ". Check the voucher number, the GL account, and confirm the "
+ "deposit has been approved by a manager.");
} }
final Map<String, Object> row = rows.get(0); final Map<String, Object> row = rows.get(0);
@@ -253,6 +256,55 @@ public class ShareAdvanceCollectionApiResource {
return Response.ok(gson.toJson(row)).build(); return Response.ok(gson.toJson(row)).build();
} }
// ══════════════════════════════════════════════════════════════════════
// GET — GL accounts selectable for a by-voucher lookup
// ══════════════════════════════════════════════════════════════════════
/**
* Every active, postable GL account, for the GL account picker on the Apply Collection screen.
*
* Deliberately not filtered to whatever a share product's own Share Suspense mapping happens to
* point at today — that mapping is exactly what this lookup no longer trusts, since it can go
* missing or wrong without anyone noticing until a voucher search fails at the counter. The person
* applying a receipt picks the account the deposit actually went to, every time.
*/
@GET
@Path("/glaccounts")
public Response glAccounts() {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, gl_code, name FROM acc_gl_account WHERE disabled = false AND account_usage = 1 ORDER BY gl_code");
final Map<String, Object> out = new LinkedHashMap<>();
out.put("glAccounts", rows);
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — What a share account needs before a collection can be applied
// ══════════════════════════════════════════════════════════════════════
/**
* Share value plus any outstanding entry fee for one share account -- shown up front on the Apply
* Collection screen so the person at the counter knows the full amount to collect (and that it may
* come in as more than one receipt, e.g. one for the share value and a separate one for the fee)
* before a search ever comes up short, rather than learning it only from an error after the fact.
*/
@GET
@Path("/requirement")
public Response requirement(@QueryParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
}
final Map<String, Object> acct = loadAccount(shareAccountId);
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("accountNo", acct.get("account_no"));
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("required", shareAmount.add(chargeAmount));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
// POST — Apply a receipt to an activated share account // POST — Apply a receipt to an activated share account
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
@@ -273,7 +325,8 @@ public class ShareAdvanceCollectionApiResource {
// A member who pays in instalments has more than one receipt against the same application: // A member who pays in instalments has more than one receipt against the same application:
// ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they // ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
// are applied together as one settlement. The path receipt leads; any others come in the // are applied together as one settlement. The path receipt leads; any others come in the
// body. // body. These are the SHARE-side receipts only -- money that actually sat in Sundry and
// genuinely needs to move out of it into Share Capital.
final List<Long> collectionIds = new java.util.ArrayList<>(); final List<Long> collectionIds = new java.util.ArrayList<>();
collectionIds.add(collectionId); collectionIds.add(collectionId);
for (final Long additionalId : getLongList(json, "additionalCollectionIds")) { for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
@@ -282,6 +335,14 @@ public class ShareAdvanceCollectionApiResource {
} }
} }
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
final List<Map<String, Object>> collections = new java.util.ArrayList<>(); final List<Map<String, Object>> collections = new java.util.ArrayList<>();
for (final Long id : collectionIds) { for (final Long id : collectionIds) {
final Map<String, Object> row = loadCollection(id); final Map<String, Object> row = loadCollection(id);
@@ -292,6 +353,15 @@ public class ShareAdvanceCollectionApiResource {
collections.add(row); collections.add(row);
} }
Map<String, Object> chargeColl = null;
if (chargeCollectionId != null) {
chargeColl = loadCollection(chargeCollectionId);
if (!"UNAPPLIED".equals(chargeColl.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
"Entry fee receipt " + chargeColl.get("receipt_no") + " is already " + chargeColl.get("status") + ".");
}
}
final Map<String, Object> coll = collections.get(0); final Map<String, Object> coll = collections.get(0);
final Map<String, Object> acct = loadAccount(shareAccountId); final Map<String, Object> acct = loadAccount(shareAccountId);
@@ -306,6 +376,10 @@ public class ShareAdvanceCollectionApiResource {
+ " belongs to a different member than share account " + acct.get("account_no") + "."); + " belongs to a different member than share account " + acct.get("account_no") + ".");
} }
} }
if (chargeColl != null && !toLong(chargeColl.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Entry fee receipt "
+ chargeColl.get("receipt_no") + " belongs to a different member than share account " + acct.get("account_no") + ".");
}
final int statusEnum = toInt(acct.get("status_enum")); final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) { if (statusEnum != STATUS_ACTIVE) {
@@ -320,7 +394,12 @@ public class ShareAdvanceCollectionApiResource {
// Share value the account was opened for, and any entry fee attached to it. // Share value the account was opened for, and any entry fee attached to it.
final BigDecimal shareAmount = shareValueOf(shareAccountId); final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId); final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
// When the fee is being verified as its own receipt, the Sundry-side receipts only need to
// cover the share value -- the fee is proved paid separately below, and its money is never
// expected to be part of what came out of Sundry. Without a separate fee receipt, the fee is
// assumed bundled into the same Sundry receipt(s) as the share value, exactly as before.
final BigDecimal requiredForShareReceipts = chargeColl != null ? shareAmount : shareAmount.add(chargeAmount);
BigDecimal receiptAmount = BigDecimal.ZERO; BigDecimal receiptAmount = BigDecimal.ZERO;
final StringBuilder receiptNos = new StringBuilder(); final StringBuilder receiptNos = new StringBuilder();
@@ -335,23 +414,41 @@ public class ShareAdvanceCollectionApiResource {
// Short is blocked: share capital cannot be part-issued — the member either bought those // Short is blocked: share capital cannot be part-issued — the member either bought those
// shares or did not — so settling for less would leave the account short by an amount owed // shares or did not — so settling for less would leave the account short by an amount owed
// by nobody. The clerk collects the balance on another receipt and applies both together. // by nobody. The clerk collects the balance on another receipt and applies both together.
if (receiptAmount.compareTo(required) < 0) { if (receiptAmount.compareTo(requiredForShareReceipts) < 0) {
final BigDecimal shortfall = required.subtract(receiptAmount); final BigDecimal shortfall = requiredForShareReceipts.subtract(receiptAmount);
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short", throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires " "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
+ required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall + requiredForShareReceipts + ". Collect the remaining " + shortfall
+ " from the member, then apply both receipts together."); + " from the member, then apply both receipts together.");
} }
// The fee receipt, when given, must cover the fee on its own -- checked separately from the
// share-side shortfall above, since the two are no longer one combined pot.
if (chargeColl != null && chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
final BigDecimal chargeReceiptAmount = (BigDecimal) chargeColl.get("amount");
if (chargeReceiptAmount.compareTo(chargeAmount) < 0) {
final BigDecimal shortfall = chargeAmount.subtract(chargeReceiptAmount);
throw new PlatformDataIntegrityException("error.msg.shareadvance.charge.amount.short",
"Entry fee receipt " + chargeColl.get("receipt_no") + " totals " + chargeReceiptAmount + " but share account "
+ acct.get("account_no") + " requires " + chargeAmount + " for the entry fee. Collect the remaining "
+ shortfall + " from the member.");
}
}
// Over-payment is allowed through rather than turning a member away over a rounded-up note. // Over-payment is allowed through rather than turning a member away over a rounded-up note.
// The difference is credited to their linked savings account below. // The difference is credited to their linked savings account below. Only the share-side
final BigDecimal excessAmount = receiptAmount.subtract(required); // excess is handled here -- an over-payment on a separately-verified fee receipt would need
// its own GL leg to fund the savings credit, which this flow does not post (that receipt's
// money is left exactly where the cashier already put it), so it is left uncollected rather
// than silently unbalancing the ledger.
final BigDecimal excessAmount = receiptAmount.subtract(requiredForShareReceipts);
final Long savingsAccountId = toLong(acct.get("savings_account_id")); final Long savingsAccountId = toLong(acct.get("savings_account_id"));
if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) { if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings", throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") "Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
+ " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings " + " requires only " + requiredForShareReceipts + ". The extra " + excessAmount
+ "account, but none is linked to this share account. Link one, or collect the exact amount."); + " must be credited to the member's savings account, but none is linked to this share account. Link one, or "
+ "collect the exact amount.");
} }
final Long officeId = toLong(acct.get("office_id")); final Long officeId = toLong(acct.get("office_id"));
@@ -367,8 +464,9 @@ public class ShareAdvanceCollectionApiResource {
final String txnId = "SHAPP-" + System.nanoTime(); final String txnId = "SHAPP-" + System.nanoTime();
final String accountNo = (String) acct.get("account_no"); final String accountNo = (String) acct.get("account_no");
// Dr Sundry for everything collected, including any over-payment — the liability those // Dr Sundry for the share-side receipts, including any over-payment on them — the liability
// receipts created is cleared in full, in one leg, so nothing is left behind in suspense. // those receipts created is cleared in full. A verified fee receipt is deliberately excluded:
// it was never in Sundry, so nothing here should debit Sundry for it.
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo); "Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
@@ -376,14 +474,19 @@ public class ShareAdvanceCollectionApiResource {
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share capital issued for " + accountNo); "Share capital issued for " + accountNo);
// Cr Income from Fees for the entry fee, when one was charged. // Cr Income from Fees for the entry fee, but only when it was NOT verified as its own receipt
// above -- that money already sits in whichever GL the cashier deposited it to (often the fee
// income account itself), so moving it again here would double it. When it was bundled into
// the same Sundry receipt(s) as the share value (chargeColl == null), it genuinely still needs
// to move, exactly as before.
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) { if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
if (chargeColl == null) {
// Resolved from the share product's "Income from Fees" mapping, which points at the // Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change. // Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income"); final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode, insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo); "Share entry fee for " + accountNo);
}
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, " jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId); + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
} }
@@ -399,23 +502,39 @@ public class ShareAdvanceCollectionApiResource {
} }
// Stamp the originating cashier deposits so the same vouchers cannot be applied twice. // Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
// Matched on voucher_no alone -- voucher numbers are unique across the whole cashier deposit
// table regardless of which GL account they were posted to, so no GL account lookup is needed
// here either. Covers the fee receipt's own deposit too, when one was verified.
for (final Map<String, Object> row : collections) { for (final Map<String, Object> row : collections) {
if (row.get("voucher_no") != null) { if (row.get("voucher_no") != null) {
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, " "UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL", + " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO); shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"));
} }
} }
if (chargeColl != null && chargeColl.get("voucher_no") != null) {
jdbcTemplate.update(
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND share_account_id IS NULL",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeColl.get("voucher_no"));
}
// Every receipt in this settlement is marked applied, not just the one in the path — they // Every receipt in this settlement is marked applied, not just the one in the path — they
// were all consumed by it, and an unmarked one would still show as money awaiting use. // were all consumed by it, and an unmarked one would still show as money awaiting use. The fee
// receipt is marked applied too, even though it posted no GL entry of its own.
for (final Long id : collectionIds) { for (final Long id : collectionIds) {
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, " "UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?", + " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id); shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
} }
if (chargeCollectionId != null) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, chargeCollectionId);
}
// Reveals the purchase transaction in the passbook and transactions views. // Reveals the purchase transaction in the passbook and transactions views.
jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId); jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
@@ -428,7 +547,8 @@ public class ShareAdvanceCollectionApiResource {
out.put("accountNo", accountNo); out.put("accountNo", accountNo);
out.put("shareAmount", shareAmount); out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount); out.put("chargeAmount", chargeAmount);
out.put("totalApplied", required); out.put("chargeReceiptVerifiedSeparately", chargeColl != null);
out.put("totalApplied", requiredForShareReceipts);
out.put("totalCollected", receiptAmount); out.put("totalCollected", receiptAmount);
out.put("glTransactionId", txnId); out.put("glTransactionId", txnId);
out.put("status", "APPLIED"); out.put("status", "APPLIED");
@@ -458,16 +578,25 @@ public class ShareAdvanceCollectionApiResource {
* clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which * clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
* is what keeps the money tied to the right member. * is what keeps the money tied to the right member.
* *
* @return the newly created collection row, or null when no approved Sundry deposit matches. * @param glAccountNoOverride
* required -- the GL account number the deposit was posted to, selected by the counter
* rather than resolved from any product's accounting configuration, so a broken or
* missing product mapping can never make a genuine voucher unreachable.
* @return the newly created collection row, or null when no approved deposit matches.
*/ */
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) { private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint, final String glAccountNoOverride) {
if (glAccountNoOverride == null || glAccountNoOverride.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.account.required",
"Select the GL account this voucher was deposited to, then search again.");
}
final String shareSundryAccountNo = glAccountNoOverride.trim();
final List<Map<String, Object>> deposits = jdbcTemplate final List<Map<String, Object>> deposits = jdbcTemplate
.queryForList( .queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id," "SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id" + " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
+ " FROM m_cashier_deposit_request d" + " FROM m_cashier_deposit_request d"
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC", + " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
voucherNo, SHARE_SUNDRY_ACCOUNT_NO); voucherNo, shareSundryAccountNo);
if (deposits.isEmpty()) { if (deposits.isEmpty()) {
return null; return null;
@@ -324,7 +324,7 @@ public class ClerkShareWithdrawalRequestApiResource {
final Long requestedShares = ((Number) record.get("requested_shares")).longValue(); final Long requestedShares = ((Number) record.get("requested_shares")).longValue();
final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList(""" final Map<String, Object> shareAccountRow = jdbcTemplate.queryForList("""
SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, collection_applied AS collectionApplied,
savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId
FROM m_share_account WHERE id = ? FROM m_share_account WHERE id = ?
""", shareAccountId).stream().findFirst().orElse(null); """, shareAccountId).stream().findFirst().orElse(null);
@@ -340,6 +340,14 @@ public class ClerkShareWithdrawalRequestApiResource {
: -1; : -1;
checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus))); checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus)));
// Active only means approved and activated -- not paid for. Real share capital is only posted
// once a Share Advance Collection is applied (see ShareAdvanceCollectionApiResource), and
// approve() below hard-blocks on this same flag, so a request failing this check can never
// actually be approved regardless of what a checker decides here.
final boolean collectionApplied = shareAccountRow != null && Boolean.TRUE.equals(shareAccountRow.get("collectionApplied"));
checks.add(checkResult("Shares have been paid for (Share Advance Collection applied)", collectionApplied,
collectionApplied ? "Settled" : "Not settled — no collection has been applied to this share account yet"));
final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null
? ((Number) shareAccountRow.get("savingsAccountId")).longValue() ? ((Number) shareAccountRow.get("savingsAccountId")).longValue()
: null; : null;
@@ -589,6 +597,20 @@ public class ClerkShareWithdrawalRequestApiResource {
final Object meetingDateObj = record.get("meeting_date"); final Object meetingDateObj = record.get("meeting_date");
final String resolutionNumber = (String) record.get("resolution_number"); final String resolutionNumber = (String) record.get("resolution_number");
// Account creation, approval and activation post no share-capital GL of their own -- a share
// account can be Active with "approved" shares that nobody has actually paid for yet, and only
// gets a real Dr Share Sundry / Cr Share Capital entry once a collection is applied (see
// ShareAdvanceCollectionApiResource). Redeeming before that would reverse capital that was
// never issued, so this is a hard block here, not just an advisory line in validate()'s
// checklist -- a checker approving off that checklist alone has no other backstop.
final Boolean collectionApplied = jdbcTemplate
.queryForList("SELECT collection_applied AS collectionApplied FROM m_share_account WHERE id = ?", shareAccountId).stream()
.findFirst().map(row -> (Boolean) row.get("collectionApplied")).orElse(null);
if (!Boolean.TRUE.equals(collectionApplied)) {
throw apiError(409, "Share account " + shareAccountId + " has not been settled yet -- no Share Advance Collection has been "
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
}
final JsonObject payload = new JsonObject(); final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en"); payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy"); payload.addProperty("dateFormat", "dd MMMM yyyy");
@@ -395,7 +395,14 @@ public class ShareProductDataSerializer {
} }
if (this.fromApiJsonHelper.parameterExists(ShareProductApiConstants.charges_paramname, element)) { if (this.fromApiJsonHelper.parameterExists(ShareProductApiConstants.charges_paramname, element)) {
final String currencyCode = this.fromApiJsonHelper.extractStringNamed(ShareProductApiConstants.currency_paramname, element); // Falls back to the product's own current currency when the update payload doesn't carry
// one -- a maker-checker edit that only touched charges replays a JSON containing just the
// submitted fields, with no currencyCode of its own, so requiring it here would make every
// charges-only edit crash rather than validate against the currency the product already has.
String currencyCode = this.fromApiJsonHelper.extractStringNamed(ShareProductApiConstants.currency_paramname, element);
if (currencyCode == null && product.getCurrency() != null) {
currencyCode = product.getCurrency().getCode();
}
Set<Charge> charges = assembleListOfProductCharges(element, currencyCode); Set<Charge> charges = assembleListOfProductCharges(element, currencyCode);
if (product.setCharges(charges)) { if (product.setCharges(charges)) {
actualChanges.put(ShareProductApiConstants.charges_paramname, charges); actualChanges.put(ShareProductApiConstants.charges_paramname, charges);
@@ -865,7 +865,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, fieldName)); this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, fieldName));
} }
final List<Long> chargeIds = ProductPendingEditFieldMapper.extractChargeIds(command); final List<Long> chargeIds = ProductPendingEditFieldMapper.extractChargeIds(command, ShareProductApiConstants.charges_paramname);
if (chargeIds != null) { if (chargeIds != null) {
this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, "charges")); this.pendingEditSubmittedFieldRepository.save(new ProductPendingEditSubmittedField(pendingEdit, "charges"));
for (final Long chargeId : chargeIds) { for (final Long chargeId : chargeIds) {
@@ -907,7 +907,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
} }
if (submittedFields.contains("charges")) { if (submittedFields.contains("charges")) {
ProductPendingEditFieldMapper.writeChargesToJson(root, ProductPendingEditFieldMapper.writeChargesToJson(root,
this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId())); this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId()), ShareProductApiConstants.charges_paramname);
} }
if (submittedFields.contains("glArrayMappings")) { if (submittedFields.contains("glArrayMappings")) {
ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root, ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root,
@@ -944,7 +944,7 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
} }
if (submittedFields.contains("charges")) { if (submittedFields.contains("charges")) {
ProductPendingEditFieldMapper.writeChargesToJson(root, ProductPendingEditFieldMapper.writeChargesToJson(root,
this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId())); this.pendingEditChargeRepository.findByPendingEditId(pendingEdit.getId()), ShareProductApiConstants.charges_paramname);
} }
if (submittedFields.contains("glArrayMappings")) { if (submittedFields.contains("glArrayMappings")) {
ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root, ProductPendingEditFieldMapper.writeGlArrayMappingsToJson(root,
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -547,10 +547,21 @@ public final class ProductPendingEditFieldMapper {
/** Returns null if "charges" wasn't present at all, else the (possibly empty) list of charge ids. */ /** Returns null if "charges" wasn't present at all, else the (possibly empty) list of charge ids. */
public static List<Long> extractChargeIds(final JsonCommand command) { public static List<Long> extractChargeIds(final JsonCommand command) {
if (!command.hasParameter("charges")) { return extractChargeIds(command, "charges");
}
/**
* Same as {@link #extractChargeIds(JsonCommand)}, but reads a caller-chosen JSON field instead of the
* "charges" every other product type uses. Share products alone submit this array under
* {@code chargesSelected} (see {@code ShareProductApiConstants.charges_paramname}) -- calling the
* default overload for a share product silently finds nothing, since {@code hasParameter("charges")}
* is always false for that payload.
*/
public static List<Long> extractChargeIds(final JsonCommand command, final String paramName) {
if (!command.hasParameter(paramName)) {
return null; return null;
} }
final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get("charges"); final com.google.gson.JsonElement raw = command.parsedJson().getAsJsonObject().get(paramName);
final List<Long> ids = new java.util.ArrayList<>(); final List<Long> ids = new java.util.ArrayList<>();
if (raw != null && raw.isJsonArray()) { if (raw != null && raw.isJsonArray()) {
for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) { for (final com.google.gson.JsonElement el : raw.getAsJsonArray()) {
@@ -563,13 +574,23 @@ public final class ProductPendingEditFieldMapper {
} }
public static void writeChargesToJson(final JsonObject root, final List<ProductPendingEditCharge> charges) { public static void writeChargesToJson(final JsonObject root, final List<ProductPendingEditCharge> charges) {
writeChargesToJson(root, charges, "charges");
}
/**
* Same as {@link #writeChargesToJson(JsonObject, List)}, but writes under a caller-chosen field name.
* The replayed JSON built here is fed straight back into the same product-update parser the original
* submission went through, so the field name must match whatever that product type's own submit
* payload used -- {@code chargesSelected} for share products, {@code charges} for every other type.
*/
public static void writeChargesToJson(final JsonObject root, final List<ProductPendingEditCharge> charges, final String paramName) {
final JsonArray array = new JsonArray(); final JsonArray array = new JsonArray();
for (final ProductPendingEditCharge charge : charges) { for (final ProductPendingEditCharge charge : charges) {
final JsonObject entry = new JsonObject(); final JsonObject entry = new JsonObject();
entry.addProperty("id", charge.getChargeId()); entry.addProperty("id", charge.getChargeId());
array.add(entry); array.add(entry);
} }
root.add("charges", array); root.add(paramName, array);
} }
// ---- accountFacilityLimits (1:many, satellite table) ------------------------------------------- // ---- accountFacilityLimits (1:many, satellite table) -------------------------------------------
@@ -355,7 +355,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
* {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures * {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures
* rather than one bucket doing double duty. */ * rather than one bucket doing double duty. */
@Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true) @Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true)
private BigDecimal savingsOnHoldAmountClearing; private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY)
protected List<InteropIdentifier> identifiers = new ArrayList<>(); protected List<InteropIdentifier> identifiers = new ArrayList<>();