fix(savings,cashier): auto-post pro-rata interest on closure, split Cash/Clearing hold, fix WITHDRAWN-status blind spots
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 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 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 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-2) (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 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 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 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 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 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 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 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-2) (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 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 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 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
Closing a savings account used to only ever validate that interest had already been posted on the closing date (opt-in, off by default) -- with nothing posted beforehand the partial period's interest was simply never paid. It now always calculates and posts it before the account closes, reusing the same machinery the manual "post interest as of date" action already relied on. Savings accounts now track two separate hold totals instead of one: a manual/staff-placed Cash Hold (the existing lien mechanism, unchanged for every caller that doesn't ask for the new category) and a Clearing Hold for money still moving through the clearing process -- a Cheque/DD cashier deposit now automatically places one for the full amount at approval time, and CTS inward-clearing holds are now bucketed here too, since both are genuinely about the clearing pipeline rather than a lien someone chose to place. A new self-contained endpoint releases due Clearing Holds (wire it into an external scheduler); the existing manual release action now credits back whichever bucket the original hold actually debited. Approving a Cheque/DD deposit dispatches this automatically as the approving manager, so the checker roles that call deposit-approval needed the underlying HOLDAMOUNT/RELEASEAMOUNT permissions granted -- without it every such approval would now fail outright; a stray column-name bug in the payment- type lookup that surfaced this in testing is fixed too, guarded going forward by a try/catch so a problem in this best-effort step can never sink the deposit approval it rides along with. A clerk withdrawal's own terminal status is WITHDRAWN, not APPROVED -- deposits and disbursements use APPROVED, withdrawal is the one register that doesn't. Three places were still filtering on APPROVED alone and silently excluding every completed withdrawal: the Day Book's CR/DR totals, and the branch day register's own "cash the branch should be holding" formula (which overstated the expected figure by exactly what had been paid out, manufacturing a phantom cash-short every time a withdrawal happened that day). The GL Entries report showed a bare internal transaction id under "Voucher No" instead of the real formatted voucher stamped on the underlying deposit/withdrawal request; it now resolves and shows that instead. Denomination rows saved at deposit/withdrawal submission never recorded which payment type was used, always leaving "Payment Type" blank regardless of what the cashier actually collected -- both insert paths now carry it through, and existing rows are backfilled. FacilityChargeSeederService no longer hardcodes an income GL account onto the facility charges (ATM, UPI, Cheque Book, NEFT, ...) it seeds. Fineract's accounting engine uses a charge's own GL account ahead of its savings product's own "Income From Fees" mapping whenever one is set, so seeding all sixteen onto the same shared account (picked essentially by accident) silently overrode every product's own accounting configuration with it. Leaving it unset lets each product's own mapping decide where this income actually posts, which is the point of that mapping existing at all. Institution/Branch Profile's READ permission was scoped to manager/admin roles only, since it started as an org-setup-only screen -- now that the Passbook and Statement screens read it for their letterhead (real bank name, license number, branch IFSC/MICR) instead of a hardcoded name, and both are reachable by a cashier/clerk/teller, the read-only permission is extended to those roles too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+39
-3
@@ -349,6 +349,14 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
|
||||
@Column(name = "total_savings_amount_on_hold", scale = 6, precision = 19, nullable = true)
|
||||
private BigDecimal savingsOnHoldAmount;
|
||||
|
||||
/** A hold placed because a cheque/DD/instrument is still in the clearing pipeline (a deposit not yet
|
||||
* realized, or a presented cheque not yet settled) -- kept as its own running total, separate from
|
||||
* {@link #savingsOnHoldAmount}'s staff-placed lien, so the two read as genuinely different figures
|
||||
* rather than one bucket doing double duty. */
|
||||
@Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = true)
|
||||
private BigDecimal savingsOnHoldAmountClearing;
|
||||
|
||||
@OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
protected List<InteropIdentifier> identifiers = new ArrayList<>();
|
||||
|
||||
@@ -3852,7 +3860,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
|
||||
public BigDecimal getWithdrawableBalance() {
|
||||
return getAccountBalance().subtract(minRequiredBalanceDerived(getCurrency()).getAmount()).subtract(this.getOnHoldFunds())
|
||||
.subtract(this.getSavingsHoldAmount());
|
||||
.subtract(this.getSavingsHoldAmount()).subtract(this.getSavingsHoldAmountClearing());
|
||||
}
|
||||
|
||||
public BigDecimal getWithdrawableBalanceWithLien() {
|
||||
@@ -4189,16 +4197,44 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
return lastransactionDate;
|
||||
}
|
||||
|
||||
/** A manual, staff-placed lien (court order, minimum-balance hold, etc.) -- distinct from a
|
||||
* {@link #HOLD_CATEGORY_CLEARING} hold, which exists because of the clearing process itself rather
|
||||
* than a decision someone made about this specific account. */
|
||||
public static final String HOLD_CATEGORY_CASH = "CASH";
|
||||
public static final String HOLD_CATEGORY_CLEARING = "CLEARING";
|
||||
|
||||
public BigDecimal getSavingsHoldAmount() {
|
||||
return this.savingsOnHoldAmount == null ? BigDecimal.ZERO : this.savingsOnHoldAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getSavingsHoldAmountClearing() {
|
||||
return this.savingsOnHoldAmountClearing == null ? BigDecimal.ZERO : this.savingsOnHoldAmountClearing;
|
||||
}
|
||||
|
||||
/** Defaults to the manual-lien bucket -- every call site written before the two-category split
|
||||
* keeps behaving exactly as it did before. */
|
||||
public void holdAmount(BigDecimal amount) {
|
||||
this.savingsOnHoldAmount = getSavingsHoldAmount().add(amount);
|
||||
holdAmount(amount, HOLD_CATEGORY_CASH);
|
||||
}
|
||||
|
||||
public void holdAmount(BigDecimal amount, String category) {
|
||||
if (HOLD_CATEGORY_CLEARING.equals(category)) {
|
||||
this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().add(amount);
|
||||
} else {
|
||||
this.savingsOnHoldAmount = getSavingsHoldAmount().add(amount);
|
||||
}
|
||||
}
|
||||
|
||||
public void releaseOnHoldAmount(BigDecimal amount) {
|
||||
this.savingsOnHoldAmount = getSavingsHoldAmount().subtract(amount);
|
||||
releaseOnHoldAmount(amount, HOLD_CATEGORY_CASH);
|
||||
}
|
||||
|
||||
public void releaseOnHoldAmount(BigDecimal amount, String category) {
|
||||
if (HOLD_CATEGORY_CLEARING.equals(category)) {
|
||||
this.savingsOnHoldAmountClearing = getSavingsHoldAmountClearing().subtract(amount);
|
||||
} else {
|
||||
this.savingsOnHoldAmount = getSavingsHoldAmount().subtract(amount);
|
||||
}
|
||||
}
|
||||
|
||||
public AccountType getAccountType() {
|
||||
|
||||
+14
@@ -126,6 +126,12 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat
|
||||
@Column(name = "reason_for_block", nullable = true)
|
||||
private String reasonForBlock;
|
||||
|
||||
/** {@link SavingsAccount#HOLD_CATEGORY_CASH} or {@link SavingsAccount#HOLD_CATEGORY_CLEARING} --
|
||||
* only meaningful on a hold-amount transaction; carried here (rather than looked up) so a later
|
||||
* release can credit the same running total the hold itself debited. */
|
||||
@Column(name = "hold_category", length = 20, nullable = true)
|
||||
private String holdCategory;
|
||||
|
||||
@Column(name = "is_reversal", nullable = false)
|
||||
private boolean reversalTransaction;
|
||||
|
||||
@@ -459,6 +465,14 @@ public final class SavingsAccountTransaction extends AbstractAuditableWithUTCDat
|
||||
this.reasonForBlock = reasonForBlock;
|
||||
}
|
||||
|
||||
public String getHoldCategory() {
|
||||
return this.holdCategory;
|
||||
}
|
||||
|
||||
public void updateHoldCategory(String holdCategory) {
|
||||
this.holdCategory = holdCategory;
|
||||
}
|
||||
|
||||
public Long getReleaseIdOfHoldAmountTransaction() {
|
||||
return this.releaseIdOfHoldAmountTransaction;
|
||||
}
|
||||
|
||||
+12
-26
@@ -72,9 +72,6 @@ public class FacilityChargeSeederService {
|
||||
@Transactional
|
||||
public void seedFacilityCharges() {
|
||||
try {
|
||||
// Find an income GL account to link charges (prefer DETAIL type income account)
|
||||
Long incomeGlAccountId = findIncomeGlAccountId();
|
||||
|
||||
for (Map.Entry<String, Object[]> entry : FACILITY_DEFAULTS.entrySet()) {
|
||||
final String facilityType = entry.getKey();
|
||||
final Object[] def = entry.getValue();
|
||||
@@ -90,12 +87,21 @@ public class FacilityChargeSeederService {
|
||||
Long.class, chargeName);
|
||||
|
||||
if (chargeIds.isEmpty()) {
|
||||
// income_or_liability_account_id deliberately left NULL: Fineract's own
|
||||
// accounting engine (AccountingProcessorHelper.getLinkedGLAccountForSavingsCharges)
|
||||
// uses a charge's own GL account, when set, ahead of the savings PRODUCT's own
|
||||
// "Income From Fees" mapping. Setting one here would silently override every
|
||||
// product's own configuration with the same shared account for all 16 facilities
|
||||
// -- exactly what happened before this fix, all landing on whichever income
|
||||
// account findIncomeGlAccountId() happened to find first. Leaving it unset lets
|
||||
// each product's own accounting setup decide where this income posts, which is
|
||||
// the actual point of a per-product "Income From Fees" mapping.
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO m_charge (name, currency_code, amount, charge_applies_to_enum, "
|
||||
+ "charge_time_enum, charge_calculation_enum, is_penalty, is_active, is_deleted, "
|
||||
+ "is_free_withdrawal, is_payment_type, income_or_liability_account_id) "
|
||||
+ "VALUES (?, 'INR', ?, ?, ?, true, false, true, false, false, false, ?)",
|
||||
chargeName, amount, chargeAppliesTo, chargeTimeType, incomeGlAccountId);
|
||||
+ "is_free_withdrawal, is_payment_type) "
|
||||
+ "VALUES (?, 'INR', ?, ?, ?, true, false, true, false, false, false)",
|
||||
chargeName, amount, chargeAppliesTo, chargeTimeType);
|
||||
|
||||
log.info("FacilityChargeSeeder: inserted charge '{}'", chargeName);
|
||||
}
|
||||
@@ -122,24 +128,4 @@ public class FacilityChargeSeederService {
|
||||
log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Long findIncomeGlAccountId() {
|
||||
try {
|
||||
// Prefer "INCOME FROM ALL" GL account for facility charges
|
||||
List<Long> ids = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND account_usage = 1 "
|
||||
+ "AND disabled = false AND name = 'INCOME FROM ALL' LIMIT 1",
|
||||
Long.class);
|
||||
if (!ids.isEmpty()) return ids.get(0);
|
||||
|
||||
// Fallback: first available income detail account
|
||||
ids = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND account_usage = 1 "
|
||||
+ "AND disabled = false ORDER BY id ASC LIMIT 1",
|
||||
Long.class);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user