cash-only registers, day book sections and DD amendments
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract 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 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 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 - 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 - PostgreSQL / 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 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 - 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-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

Cashier reports now reconcile against the drawer:
- Received/Payment exclude what cannot be cash rather than admitting
  only proven cash, so registers that pay out without recording a
  denomination breakdown are no longer silently dropped
- Closing report scoped to the cashier, with opening/closing balance,
  reversed counter transactions excluded, and cash DD receipts counted
- Day book split into Cashier and Clerk sections, filed by whose cash
  moved rather than who raised the request

Fixed one deposit or withdrawal raising two vouchers: the cashier
deposit and clerk withdrawal registers did not suppress the inner
savings command's voucher, doubling the day's cash on both reports.

Sponsor DD:
- Amendment request flow (beneficiary, payable-at branch and city)
  under maker-checker; face value and funding stay unamendable
- Cash DD denominations were collected but never saved, and the amount
  check demanded a figure notes could not add to; both fixed and the
  breakdown is now required

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-03 18:45:36 +05:30
co-authored by Claude Opus 5
parent a04b65583e
commit e4cc2e558e
90 changed files with 10606 additions and 6511 deletions
@@ -56,8 +56,9 @@ public class SavingsProductAccountingDataValidator {
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue())
.value(savingsReferenceAccountId).notNull().integerGreaterThanZero();
}
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)
|| ignoreExistenceValidation) {
// Transfers In Suspense is not offered by the savings product screen, so it is validated only
// when actually supplied — ignoreExistenceValidation must not make it mandatory on create.
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
final Long transfersInSuspenseAccountId = fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
@@ -85,22 +86,21 @@ public class SavingsProductAccountingDataValidator {
.value(incomeFromPenaltyId).notNull().integerGreaterThanZero();
}
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element)
|| ignoreExistenceValidation) {
// Overdraft Control / Income From Interest / Write Off are likewise not offered by the
// savings product screen, so they too are validated only when actually supplied.
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element)) {
final Long overdraftControlAccountId = fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
.value(overdraftControlAccountId).notNull().integerGreaterThanZero();
}
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)
|| ignoreExistenceValidation) {
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
final Long incomeFromInterest = fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
.value(incomeFromInterest).notNull().integerGreaterThanZero();
}
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)
|| ignoreExistenceValidation) {
if (fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
final Long writtenoff = fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenoff)
@@ -0,0 +1,158 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* Record that an escheated balance was actually remitted to RBI's Depositor Education and Awareness
* Fund.
*
* <p>
* Escheat and remittance are two separate events. The nightly job closes the account and takes the
* balance out; sending that money to RBI happens outside this system, on RBI's own schedule. Until
* a row exists here the balance is out of the customer's account but not accounted for as having
* reached the fund, and the escheat report says exactly that rather than assuming it was sent.
*
* <p>
* A customer can claim an unclaimed balance at any time even after remittance: the bank pays them
* and recovers the amount from the fund. {@code refundedOn} records that, so a remittance that has
* since been drawn back is not counted twice.
*
* <p>
* One row per savings account — an account's balance goes to DEAF once.
*/
@Entity
@Table(name = "m_deaf_remittance")
public class DeafRemittance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "savings_account_id", nullable = false, unique = true)
private Long savingsAccountId;
@Column(name = "client_id", nullable = false)
private Long clientId;
/** The amount taken out of the account at escheat, which is what is owed to the fund. */
@Column(name = "amount", nullable = false, scale = 6, precision = 19)
private BigDecimal amount;
/** The day the account was closed and the balance removed. */
@Column(name = "escheated_on", nullable = false)
private LocalDate escheatedOn;
/** The day the money was sent to RBI. */
@Column(name = "remitted_on", nullable = false)
private LocalDate remittedOn;
/** RBI's acknowledgement for the remittance, as printed on their advice. */
@Column(name = "deaf_reference", length = 100)
private String deafReference;
/** Set only if the customer later claimed the balance and it was recovered from the fund. */
@Column(name = "refunded_on")
private LocalDate refundedOn;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "recorded_by_user_id")
private Long recordedByUserId;
@Column(name = "recorded_on", nullable = false)
private LocalDateTime recordedOn;
protected DeafRemittance() {
// JPA
}
private DeafRemittance(final Long savingsAccountId, final Long clientId, final BigDecimal amount,
final LocalDate escheatedOn, final LocalDate remittedOn, final String deafReference, final String remarks,
final Long recordedByUserId) {
this.savingsAccountId = savingsAccountId;
this.clientId = clientId;
this.amount = amount;
this.escheatedOn = escheatedOn;
this.remittedOn = remittedOn;
this.deafReference = deafReference;
this.remarks = remarks;
this.recordedByUserId = recordedByUserId;
this.recordedOn = LocalDateTime.now();
}
public static DeafRemittance record(final Long savingsAccountId, final Long clientId, final BigDecimal amount,
final LocalDate escheatedOn, final LocalDate remittedOn, final String deafReference, final String remarks,
final Long recordedByUserId) {
return new DeafRemittance(savingsAccountId, clientId, amount, escheatedOn, remittedOn, deafReference, remarks,
recordedByUserId);
}
/** Records that the customer claimed the balance back and it was recovered from the fund. */
public void markRefunded(final LocalDate refundedOn) {
this.refundedOn = refundedOn;
}
public Long getId() {
return id;
}
public Long getSavingsAccountId() {
return savingsAccountId;
}
public Long getClientId() {
return clientId;
}
public BigDecimal getAmount() {
return amount;
}
public LocalDate getEscheatedOn() {
return escheatedOn;
}
public LocalDate getRemittedOn() {
return remittedOn;
}
public String getDeafReference() {
return deafReference;
}
public LocalDate getRefundedOn() {
return refundedOn;
}
public String getRemarks() {
return remarks;
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DeafRemittanceRepository extends JpaRepository<DeafRemittance, Long> {
Optional<DeafRemittance> findBySavingsAccountId(Long savingsAccountId);
boolean existsBySavingsAccountId(Long savingsAccountId);
List<DeafRemittance> findBySavingsAccountIdIn(List<Long> savingsAccountIds);
}
@@ -68,9 +68,6 @@ public class FacilityConfigEditFields {
@Column(name = "fc_inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "fc_withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "fc_deposit_facility_kyc")
private Boolean depositFacilityKyc;
@@ -417,7 +417,6 @@ public final class ProductPendingEditFieldMapper {
f.setNeftFacility(booleanOf(command, "accountFacilityConfig", "neftFacility"));
f.setRtgsFacility(booleanOf(command, "accountFacilityConfig", "rtgsFacility"));
f.setInterBankTotalCapFacility(booleanOf(command, "accountFacilityConfig", "interBankTotalCapFacility"));
f.setWithdrawalFacility(booleanOf(command, "accountFacilityConfig", "withdrawalFacility"));
f.setDepositFacilityKyc(booleanOf(command, "accountFacilityConfig", "depositFacilityKyc"));
f.setVirtualDebitCard(booleanOf(command, "accountFacilityConfig", "virtualDebitCard"));
return true;
@@ -441,7 +440,6 @@ public final class ProductPendingEditFieldMapper {
config.addProperty("neftFacility", f.getNeftFacility());
config.addProperty("rtgsFacility", f.getRtgsFacility());
config.addProperty("interBankTotalCapFacility", f.getInterBankTotalCapFacility());
config.addProperty("withdrawalFacility", f.getWithdrawalFacility());
config.addProperty("depositFacilityKyc", f.getDepositFacilityKyc());
config.addProperty("virtualDebitCard", f.getVirtualDebitCard());
root.add("accountFacilityConfig", config);
@@ -75,9 +75,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc = false;
@@ -138,9 +135,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom<Long
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
@@ -162,7 +156,6 @@ public class SavingsAccountFacilityConfig extends AbstractPersistableCustom<Long
if (config.has("neftFacility")) setNeftFacility(config.get("neftFacility").getAsBoolean());
if (config.has("rtgsFacility")) setRtgsFacility(config.get("rtgsFacility").getAsBoolean());
if (config.has("interBankTotalCapFacility")) setInterBankTotalCapFacility(config.get("interBankTotalCapFacility").getAsBoolean());
if (config.has("withdrawalFacility")) setWithdrawalFacility(config.get("withdrawalFacility").getAsBoolean());
if (config.has("depositFacilityKyc")) setDepositFacilityKyc(config.get("depositFacilityKyc").getAsBoolean());
if (config.has("virtualDebitCard")) setVirtualDebitCard(config.get("virtualDebitCard").getAsBoolean());
}
@@ -134,9 +134,6 @@ public class SavingsAccountPendingEdit extends AbstractPersistableCustom<Long> {
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc;
@@ -371,8 +371,6 @@ public class SavingsProductAssembler {
facilityConfigJson.get("rtgsFacility").getAsBoolean() : false);
config.setInterBankTotalCapFacility(facilityConfigJson.has("interBankTotalCapFacility") ?
facilityConfigJson.get("interBankTotalCapFacility").getAsBoolean() : false);
config.setWithdrawalFacility(facilityConfigJson.has("withdrawalFacility") ?
facilityConfigJson.get("withdrawalFacility").getAsBoolean() : false);
config.setDepositFacilityKyc(facilityConfigJson.has("depositFacilityKyc") ?
facilityConfigJson.get("depositFacilityKyc").getAsBoolean() : false);
config.setVirtualDebitCard(facilityConfigJson.has("virtualDebitCard") ?
@@ -75,9 +75,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility")
private Boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility")
private Boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc")
private Boolean depositFacilityKyc = false;
@@ -138,9 +135,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom<Long
public Boolean getInterBankTotalCapFacility() { return interBankTotalCapFacility; }
public void setInterBankTotalCapFacility(Boolean v) { this.interBankTotalCapFacility = v != null ? v : false; }
public Boolean getWithdrawalFacility() { return withdrawalFacility; }
public void setWithdrawalFacility(Boolean v) { this.withdrawalFacility = v != null ? v : false; }
public Boolean getDepositFacilityKyc() { return depositFacilityKyc; }
public void setDepositFacilityKyc(Boolean v) { this.depositFacilityKyc = v != null ? v : false; }
@@ -163,7 +157,6 @@ public class SavingsProductFacilityConfig extends AbstractPersistableCustom<Long
this.neftFacility = other.getNeftFacility();
this.rtgsFacility = other.getRtgsFacility();
this.interBankTotalCapFacility = other.getInterBankTotalCapFacility();
this.withdrawalFacility = other.getWithdrawalFacility();
this.depositFacilityKyc = other.getDepositFacilityKyc();
this.virtualDebitCard = other.getVirtualDebitCard();
}
@@ -81,7 +81,6 @@ public class FacilityChargeAutoApplyService {
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
@@ -305,7 +304,6 @@ public class FacilityChargeAutoApplyService {
applyIfEnabled(account, Boolean.TRUE.equals(config.getNeftFacility()), "NEFT_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getRtgsFacility()), "RTGS_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getInterBankTotalCapFacility()),"INTER_BANK_TOTAL_CAP",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getWithdrawalFacility()), "WITHDRAWAL_FACILITY", chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getDepositFacilityKyc()), "DEPOSIT_FACILITY_KYC",chargesToApply);
applyIfEnabled(account, Boolean.TRUE.equals(config.getVirtualDebitCard()), "VIRTUAL_DEBIT_CARD", chargesToApply);
if (!chargesToApply.isEmpty()) {
@@ -18,30 +18,43 @@
*/
package org.apache.fineract.portfolio.savings.service;
import jakarta.annotation.PostConstruct;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.annotation.DependsOn;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Seeds default facility charges on service startup via JDBC, one per facility type (ATM, UPI,
* NEFT etc.), so the Facility Charge Mappings admin screen has ready-made charges to choose from.
* Deliberately does NOT link a charge to its facility's m_facility_charge_mapping row or activate
* it — that mapping must be an explicit admin choice made through that screen (an unmapped facility
* is blocked from being enabled on an account by FacilityChargeAutoApplyService). Seeder only
* creates charges that are missing — safe to run repeatedly.
* NEFT etc.), and points each facility's m_facility_charge_mapping row at its own default charge,
* so the Facility Charge Mappings admin screen comes up correctly configured on any install rather
* than showing an unmapped dash on every row.
* <p>
* Mappings are seeded INACTIVE on purpose. Activating a mapping is what actually starts charging
* the customer, so that stays an explicit admin decision made through that screen —
* FacilityChargeAutoApplyService treats an inactive mapping exactly like an unmapped one and blocks
* the facility from being enabled until an admin flips Active. Seeding the charge only removes the
* tedium of picking the obvious one; it does not pre-approve any billing.
* <p>
* Every step is guarded (charges created only when missing, mappings linked only while still
* unmapped), so an admin's own choice of charge or amount is never overwritten and the seeder is
* safe to run repeatedly and against existing databases.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FacilityChargeSeederService {
@DependsOn({ "tenantDetailsService", "tenantDatabaseUpgradeService" })
public class FacilityChargeSeederService implements InitializingBean {
private final JdbcTemplate jdbcTemplate;
private final TenantDetailsService tenantDetailsService;
// facilityType → [chargeName, defaultAmount, chargeTimeType, chargeAppliesTo]
// chargeTimeType: 3=SAVINGS_ACTIVATION, 6=ANNUAL_FEE, 7=MONTHLY_FEE
@@ -63,13 +76,50 @@ public class FacilityChargeSeederService {
FACILITY_DEFAULTS.put("NEFT_FACILITY", new Object[]{"NEFT Facility Charge", 5.00, 3, 2});
FACILITY_DEFAULTS.put("RTGS_FACILITY", new Object[]{"RTGS Facility Charge", 25.00, 3, 2});
FACILITY_DEFAULTS.put("INTER_BANK_TOTAL_CAP", new Object[]{"Inter-Bank Cap Charge", 0.00, 3, 2});
FACILITY_DEFAULTS.put("WITHDRAWAL_FACILITY", new Object[]{"Withdrawal Facility Charge", 0.00, 3, 2});
FACILITY_DEFAULTS.put("DEPOSIT_FACILITY_KYC", new Object[]{"Deposit KYC Charge", 0.00, 3, 2});
FACILITY_DEFAULTS.put("VIRTUAL_DEBIT_CARD", new Object[]{"Virtual Debit Card Charge", 199.00, 3, 2});
}
@PostConstruct
@Transactional
/**
* Runs once per tenant after the tenant datasource and Liquibase migrations are ready.
* <p>
* This deliberately does NOT use {@code @PostConstruct}: that fires while the bean graph is still
* being built, before Fineract's per-tenant routing datasource is available, so every tenant table
* lookup failed with "bad SQL grammar" and the seeder silently skipped on every startup. Seeding
* has to happen after {@code tenantDatabaseUpgradeService} has migrated the schema, with the tenant
* bound to the thread so the routing datasource resolves to that tenant's database.
*/
@Override
public void afterPropertiesSet() {
final List<FineractPlatformTenant> tenants = tenantDetailsService.findAllTenants();
if (tenants == null || tenants.isEmpty()) {
log.warn("FacilityChargeSeeder: no tenants found, skipping.");
return;
}
final FineractPlatformTenant originalTenant = ThreadLocalContextUtil.getTenant();
try {
for (final FineractPlatformTenant tenant : tenants) {
try {
ThreadLocalContextUtil.setTenant(tenant);
seedFacilityCharges();
} catch (Exception e) {
// Seeding defaults must never take the platform down for one bad tenant.
log.warn("FacilityChargeSeeder: skipped for tenant '{}' — {}", tenant.getTenantIdentifier(),
e.getMessage(), e);
}
}
} finally {
ThreadLocalContextUtil.setTenant(originalTenant);
}
}
/**
* Not {@code @Transactional}: this is invoked directly from {@link #afterPropertiesSet()} on the same
* bean, so Spring's proxy would be bypassed and the annotation would be a false promise. It needs no
* transaction anyway — every statement is individually guarded and idempotent, so a partial run simply
* completes on the next startup.
*/
public void seedFacilityCharges() {
try {
for (Map.Entry<String, Object[]> entry : FACILITY_DEFAULTS.entrySet()) {
@@ -107,8 +157,7 @@ public class FacilityChargeSeederService {
}
// 2. Ensure the facility_charge_mapping row itself exists so the admin screen lists every
// facility type, but leave it unmapped/inactive — an admin must explicitly pick a charge
// for it via the Facility Charge Mappings screen before it can be enabled on an account.
// facility type. Inserted inactive and unmapped; step 3 below fills in the charge.
List<Long> existingMapping = jdbcTemplate.queryForList(
"SELECT id FROM m_facility_charge_mapping WHERE facility_type = ?", Long.class, facilityType);
@@ -121,11 +170,37 @@ public class FacilityChargeSeederService {
log.info("FacilityChargeSeeder: added unmapped facility row '{}'", facilityType);
}
// 3. Point each still-unmapped facility at its own default charge (ATM_WITHDRAWAL ->
// "ATM Withdrawal Charge", ...) so a fresh install shows the correct charge on every row
// instead of a dash, on any machine, without an admin re-picking all 15 by hand.
//
// is_active is deliberately left false: activating the mapping is what actually starts
// billing the customer, and that must stay an explicit admin decision made through the
// Facility Charge Mappings screen (FacilityChargeAutoApplyService.requireMappedCharge
// treats an inactive mapping exactly like an unmapped one, so nothing is charged until
// an admin flips Active). This only removes the tedium of choosing the obvious charge,
// not the approval step.
//
// Scoped to charge_id IS NULL so a mapping or amount an admin has already set is never
// overwritten -- that makes this safe to re-run on every startup and on existing databases.
final int linked = jdbcTemplate.update(
"UPDATE m_facility_charge_mapping SET charge_id = "
+ "(SELECT id FROM m_charge WHERE name = ? AND is_deleted = false ORDER BY id LIMIT 1) "
+ "WHERE facility_type = ? AND charge_id IS NULL",
chargeName, facilityType);
if (linked > 0) {
log.info("FacilityChargeSeeder: mapped facility '{}' to charge '{}' (left inactive"
+ " -- an admin must activate it before it applies)", facilityType, chargeName);
}
}
log.info("FacilityChargeSeeder: seeding complete.");
} catch (Exception e) {
log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage());
// Log the cause, not just the message: this swallowed a startup-ordering failure silently
// for a long time because the underlying SQLException never reached the log.
log.warn("FacilityChargeSeeder: skipped — {}", e.getMessage(), e);
}
}
}
@@ -1,57 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Sequence is starting from 2000 to make it easier to move existing liquibase changesets here -->
<include file="parts/2001_add_savings_accrual_job.xml" relativeToChangelogFile="true" />
<include file="parts/2002_add_savings_accrual_permission.xml" relativeToChangelogFile="true" />
<include file="parts/2003_add_accrued_till_date_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2004_add_savings_account_cob_infrastructure.xml" relativeToChangelogFile="true" />
<include file="parts/2005_add_savings_product_classification_code_values.xml" relativeToChangelogFile="true" />
<include file="parts/2006_add_period_frequency_options.xml" relativeToChangelogFile="true" />
<include file="parts/2007_add_interest_calculation_type_option.xml" relativeToChangelogFile="true" />
<include file="parts/2008_add_other_gl_mappings_to_savings_product.xml" relativeToChangelogFile="true" />
<include file="parts/2009_add_gl_account_id_to_savings_product.xml" relativeToChangelogFile="true" />
<include file="parts/2010_add_savings_account_cbs_fields.xml" relativeToChangelogFile="true" />
<include file="parts/2011_add_facility_charge_mapping.xml" relativeToChangelogFile="true" />
<include file="parts/2012_add_current_account_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2013_add_missing_current_account_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2014_add_is_mandatory_to_joint_holders.xml" relativeToChangelogFile="true" />
<include file="parts/2015_add_senior_citizen_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2016_add_share_linking_enabled_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2017_add_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2018_fix_saving_product_temp_lock_constraint.xml" relativeToChangelogFile="true" />
<include file="parts/2019_add_command_source_link_to_saving_product_temp.xml" relativeToChangelogFile="true" />
<include file="parts/2020_generalize_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2021_add_fd_rd_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2022_add_product_checker_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2023_fix_product_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2025_recreate_savings_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2026_add_pending_edit_typed_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2027_add_pending_edit_satellite_tables.xml" relativeToChangelogFile="true" />
<include file="parts/2028_update_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2029_remove_proposed_payload_json.xml" relativeToChangelogFile="true" />
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2032_add_share_account_id_to_savings_account.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Sequence is starting from 2000 to make it easier to move existing liquibase changesets here -->
<include file="parts/2001_add_savings_accrual_job.xml" relativeToChangelogFile="true" />
<include file="parts/2002_add_savings_accrual_permission.xml" relativeToChangelogFile="true" />
<include file="parts/2003_add_accrued_till_date_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2004_add_savings_account_cob_infrastructure.xml" relativeToChangelogFile="true" />
<include file="parts/2005_add_savings_product_classification_code_values.xml" relativeToChangelogFile="true" />
<include file="parts/2006_add_period_frequency_options.xml" relativeToChangelogFile="true" />
<include file="parts/2007_add_interest_calculation_type_option.xml" relativeToChangelogFile="true" />
<include file="parts/2008_add_other_gl_mappings_to_savings_product.xml" relativeToChangelogFile="true" />
<include file="parts/2009_add_gl_account_id_to_savings_product.xml" relativeToChangelogFile="true" />
<include file="parts/2010_add_savings_account_cbs_fields.xml" relativeToChangelogFile="true" />
<include file="parts/2011_add_facility_charge_mapping.xml" relativeToChangelogFile="true" />
<include file="parts/2012_add_current_account_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2013_add_missing_current_account_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2014_add_is_mandatory_to_joint_holders.xml" relativeToChangelogFile="true" />
<include file="parts/2015_add_senior_citizen_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2016_add_share_linking_enabled_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2017_add_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2018_fix_saving_product_temp_lock_constraint.xml" relativeToChangelogFile="true" />
<include file="parts/2019_add_command_source_link_to_saving_product_temp.xml" relativeToChangelogFile="true" />
<include file="parts/2020_generalize_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2021_add_fd_rd_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2022_add_product_checker_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2023_fix_product_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2025_recreate_savings_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2026_add_pending_edit_typed_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2027_add_pending_edit_satellite_tables.xml" relativeToChangelogFile="true" />
<include file="parts/2028_update_saving_product_temp_table.xml" relativeToChangelogFile="true" />
<include file="parts/2029_remove_proposed_payload_json.xml" relativeToChangelogFile="true" />
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2032_add_share_account_id_to_savings_account.xml" relativeToChangelogFile="true" />
<include file="parts/2032_remove_withdrawal_facility.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -188,4 +188,21 @@
);
</sql>
</changeSet>
<changeSet author="fineract" id="2005-8-savings-product-account-type-add-normal-saving-account">
<sql>
INSERT INTO m_code (code_name, is_system_defined)
SELECT 'SavingsProductAccountType', TRUE
WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'SavingsProductAccountType');
INSERT INTO m_code_value (code_id, code_value, order_position, code_description, is_active, is_mandatory)
SELECT c.id, 'Normal Saving Account', 4, 'Normal Saving Account', TRUE, FALSE
FROM m_code c
WHERE c.code_name = 'SavingsProductAccountType'
AND NOT EXISTS (
SELECT 1 FROM m_code_value cv
WHERE cv.code_id = c.id AND cv.code_value = 'Normal Saving Account'
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Retires the "Withdrawal Facility" account feature and its auto-applied charge.
The facility flag is gone from the product/account APIs and UI, so the charge can no
longer be switched on for anything new. This changeset stops it billing going forward
and hides it from charge pickers, while leaving already-posted transactions intact so
historical statements and the trial balance still reconcile.
The withdrawal_facility / fc_withdrawal_facility columns are deliberately NOT dropped:
they are simply no longer mapped or read. Dropping them would be irreversible and buys
nothing, and the historical changesets that created them stay untouched so Liquibase
checksums keep validating.
-->
<!-- Step 1: unmap the facility type so the seeder/auto-apply can never resolve a charge -->
<changeSet id="2032-1" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_facility_charge_mapping" />
</preConditions>
<sql>
DELETE FROM m_facility_charge_mapping WHERE facility_type = 'WITHDRAWAL_FACILITY';
</sql>
</changeSet>
<!-- Step 2: drop pending (unpaid, unwaived) withdrawal-facility charges from savings accounts.
Paid or waived rows are left alone so posted accounting entries stay balanced. -->
<changeSet id="2032-2" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_account_charge" />
</preConditions>
<sql>
DELETE FROM m_savings_account_charge
WHERE charge_id IN (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge')
AND COALESCE(is_paid_derived, false) = false
AND COALESCE(waived, false) = false;
</sql>
</changeSet>
<!-- Step 3: soft-delete + deactivate the charge definition so it disappears from
charge dropdowns without breaking FKs from any historical charge rows. -->
<changeSet id="2032-3" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = false</sqlCheck>
</preConditions>
<sql>
UPDATE m_charge SET is_active = false, is_deleted = true
WHERE name = 'Withdrawal Facility Charge';
</sql>
</changeSet>
<!-- Step 4: stop the flag driving anything if a stale row is still set -->
<changeSet id="2032-4" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_savings_account_facility_config" />
<columnExists tableName="m_savings_account_facility_config" columnName="withdrawal_facility" />
</and>
</preConditions>
<sql>
UPDATE m_savings_account_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true;
</sql>
</changeSet>
<changeSet id="2032-5" author="cbs-keshav">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_savings_product_facility_config" />
<columnExists tableName="m_savings_product_facility_config" columnName="withdrawal_facility" />
</and>
</preConditions>
<sql>
UPDATE m_savings_product_facility_config SET withdrawal_facility = false WHERE withdrawal_facility = true;
</sql>
</changeSet>
</databaseChangeLog>