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
+68
View File
@@ -0,0 +1,68 @@
-- Removes the duplicate vouchers left behind by the double-count bug.
--
-- Until 2026-09-03 the cashier deposit and clerk withdrawal registers did not wrap their inner
-- savings command in VoucherService.withOwnVoucher(...), so one counter transaction raised two
-- vouchers over the same money: the register's own (CASHIER_DEPOSIT_REQUEST / CLERK_WITHDRAWAL_
-- REQUEST) and a second one from the savings command (SAVINGS_TRANSACTION). The cashier's Received
-- and Payment reports counted both, doubling the day's cash.
--
-- The code is fixed, so no new duplicates are created. This clears the ones already recorded.
--
-- WHICH ONE IS DELETED: the SAVINGS_TRANSACTION voucher. The register's voucher is the keeper --
-- it is the one the register owns, attaches its DR/CR lines to, and stamps onto
-- m_savings_account_transaction.voucher_no. Deleting the register's voucher instead would break
-- that link and orphan the request row.
--
-- Run: psql -h localhost -U postgres -d fineract_default -v ON_ERROR_STOP=1 -f cleanup_duplicate_vouchers.sql
BEGIN;
-- The duplicates: a SAVINGS_TRANSACTION voucher whose transaction is already claimed by a
-- register voucher. Matching through savings_txn_id rather than by date or amount so that two
-- genuinely separate transactions of the same value on the same day are never confused.
CREATE TEMP TABLE dup_vouchers ON COMMIT DROP AS
SELECT sav.voucher_no
FROM m_voucher sav
JOIN m_voucher reg
ON reg.savings_txn_id = sav.reference_id
AND reg.reference_type IN ('CLERK_WITHDRAWAL_REQUEST', 'CASHIER_DEPOSIT_REQUEST')
WHERE sav.reference_type = 'SAVINGS_TRANSACTION'
AND sav.voucher_type IN ('DEPOSIT', 'WITHDRAWAL');
-- Refuse to run if anything else has attached itself to a voucher about to be deleted: a
-- verification or an attachment means someone acted on that slip, and it must be looked at by hand
-- rather than removed silently.
DO $$
DECLARE
blocked INT;
BEGIN
SELECT COUNT(*) INTO blocked
FROM (SELECT voucher_no FROM m_voucher_verification
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)
UNION ALL
SELECT voucher_no FROM m_voucher_attachment
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers)) x;
IF blocked > 0 THEN
RAISE EXCEPTION 'Aborting: % duplicate voucher(s) carry a verification or attachment. Review these by hand.', blocked;
END IF;
END $$;
-- Likewise refuse if a savings transaction is stamped with a voucher about to be deleted. It should
-- carry the register's number, not the duplicate's; if it does not, the pair needs looking at.
DO $$
DECLARE
stamped INT;
BEGIN
SELECT COUNT(*) INTO stamped FROM m_savings_account_transaction
WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
IF stamped > 0 THEN
RAISE EXCEPTION 'Aborting: % savings transaction(s) are stamped with a duplicate voucher number.', stamped;
END IF;
END $$;
SELECT voucher_no AS "about to delete" FROM dup_vouchers ORDER BY voucher_no;
DELETE FROM m_voucher_line WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
DELETE FROM m_voucher WHERE voucher_no IN (SELECT voucher_no FROM dup_vouchers);
COMMIT;
@@ -127,14 +127,6 @@ SELECT 'Inter-Bank Cap Charge', 'INR', 0.00, 2, 3, 1, 0, 1, 0, 0, 0,
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0);
INSERT IGNORE 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)
SELECT 'Withdrawal Facility Charge', 'INR', 5.00, 2, 3, 1, 0, 1, 0, 0, 0,
(SELECT id FROM acc_gl_account WHERE classification_enum = 4 AND usage_enum = 1 AND disabled = 0 ORDER BY id LIMIT 1)
WHERE NOT EXISTS (SELECT 1 FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0);
INSERT IGNORE INTO m_charge
(name, currency_code, amount, charge_applies_to_enum, charge_time_enum,
charge_calculation_enum, is_penalty, is_active, is_deleted,
@@ -168,6 +160,5 @@ VALUES
('NEFT_FACILITY', 'NEFT Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'NEFT Facility Charge' AND is_deleted = 0 LIMIT 1)),
('RTGS_FACILITY', 'RTGS Facility Charge', 25.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'RTGS Facility Charge' AND is_deleted = 0 LIMIT 1)),
('INTER_BANK_TOTAL_CAP','Inter-Bank Cap Charge', 0.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Inter-Bank Cap Charge' AND is_deleted = 0 LIMIT 1)),
('WITHDRAWAL_FACILITY', 'Withdrawal Facility Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Withdrawal Facility Charge' AND is_deleted = 0 LIMIT 1)),
('DEPOSIT_FACILITY_KYC','Deposit KYC Charge', 5.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Deposit KYC Charge' AND is_deleted = 0 LIMIT 1)),
('VIRTUAL_DEBIT_CARD', 'Virtual Debit Card Charge', 199.00, 3, 2, (SELECT id FROM m_charge WHERE name = 'Virtual Debit Card Charge' AND is_deleted = 0 LIMIT 1));
-4
View File
@@ -17,7 +17,6 @@ ALTER TABLE m_current_product_facility_config
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
@@ -36,7 +35,6 @@ ALTER TABLE m_current_product_facility_config
ADD CONSTRAINT fk_cpfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_cpfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
@@ -55,7 +53,6 @@ ALTER TABLE m_savings_product_facility_config
ADD COLUMN IF NOT EXISTS neft_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS rtgs_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS inter_bank_total_cap_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS withdrawal_facility_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS deposit_facility_kyc_charge_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS virtual_debit_card_charge_id BIGINT NULL;
@@ -74,6 +71,5 @@ ALTER TABLE m_savings_product_facility_config
ADD CONSTRAINT fk_spfc_neft_charge FOREIGN KEY (neft_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_rtgs_charge FOREIGN KEY (rtgs_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_ibtc_charge FOREIGN KEY (inter_bank_total_cap_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_wd_charge FOREIGN KEY (withdrawal_facility_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_dep_charge FOREIGN KEY (deposit_facility_kyc_charge_id) REFERENCES m_charge(id),
ADD CONSTRAINT fk_spfc_vdc_charge FOREIGN KEY (virtual_debit_card_charge_id) REFERENCES m_charge(id);
@@ -181,10 +181,14 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
baseDataValidator.reset().parameter(SavingProductAccountingParams.SAVINGS_REFERENCE.getValue()).value(savingsReferenceAccountId)
.notNull().integerGreaterThanZero();
// Transfers In Suspense is not offered by every savings product UI, so it is validated only
// when the client actually sends it. Omitting it leaves the mapping unset rather than failing.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element)) {
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
}
final Long interestOnSavingsAccountId = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INTEREST_ON_SAVINGS.getValue(), element);
@@ -210,20 +214,29 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
}
if (!accountType.equals(DepositAccountType.RECURRING_DEPOSIT) && !accountType.equals(DepositAccountType.FIXED_DEPOSIT)) {
// Overdraft Control / Income From Interest / Write Off apply to overdraft-capable products
// only, so they are validated when supplied rather than demanded from every savings product.
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(),
element)) {
final Long overdraftAccount = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue())
.value(overdraftAccount).notNull().integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element)) {
final Long incomeFromInterest = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue()).value(incomeFromInterest)
.notNull().integerGreaterThanZero();
baseDataValidator.reset().parameter(SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue())
.value(incomeFromInterest).notNull().integerGreaterThanZero();
}
final Long writtenOff = this.fromApiJsonHelper.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(),
element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff).notNull()
.integerGreaterThanZero();
if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element)) {
final Long writtenOff = this.fromApiJsonHelper
.extractLongNamed(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
baseDataValidator.reset().parameter(SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue()).value(writtenOff)
.notNull().integerGreaterThanZero();
}
}
}
@@ -41,6 +41,8 @@ public enum JobName {
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
@@ -484,6 +484,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
actualChanges.put(riskParam, newValue);
recordOverwrite(riskParam, this.riskCategory);
this.riskCategory = newValue;
// The re-KYC cycle is a property of the risk category, so moving the category moves the
// due date with it — otherwise a customer raised to High keeps a Low customer's ten-year
// renewal and is never called back in for the two-yearly review the rating requires.
final LocalDate newExpiry = recalculateExpiryForRiskCategory();
if (newExpiry != null) {
actualChanges.put("expiryDate", newExpiry);
}
}
// remarks is the audit justification itself, so a change to it is deliberately not recorded
@@ -677,6 +685,34 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
}
}
/**
* Re-dates the re-KYC due date after the risk category moves.
*
* The cycle length belongs to the category the customer is in <em>now</em> (Low 10, Medium 8,
* High 2), and it is counted from the day of the change rather than from the original
* verification. A customer moved to High in year two of a Low rating is then due in two years'
* time, not immediately — dating it from {@code verifiedOn} would push most of the book into
* an overdue queue the moment a re-rating ran.
*
* <p>Only a verified KYC carries an expiry: an unverified record has nothing to renew, and
* {@link #markVerified} sets the date when it is first verified.
*
* @return the new expiry date, or {@code null} if the record carries no expiry
*/
private LocalDate recalculateExpiryForRiskCategory() {
if (this.verifiedOn == null) {
return null;
}
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
if (risk.getReKycYears() <= 0) {
return this.expiryDate;
}
this.expiryDate = DateUtils.getLocalDateOfTenant().plusYears(risk.getReKycYears());
return this.expiryDate;
}
/** Called when verification fails. */
public void markRejected(final String remarks) {
this.kycStatus = ClientKycStatus.REJECTED.getValue();
@@ -798,6 +834,9 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
*/
public void escalateRiskCategory(Integer newCategory) {
this.riskCategory = newCategory;
// An auto-escalation moves the rating for the same reason an officer would, so it moves the
// re-KYC due date on the same terms — see recalculateExpiryForRiskCategory().
recalculateExpiryForRiskCategory();
}
public String getPanNumber() {
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.client.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -48,6 +49,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
/**
* KYC records whose periodic re-KYC due date has passed, oldest first.
*
* <p>Only records that carry an expiry are considered: an expiry is set when the KYC is first
* verified, so one without a date has never been verified and has no renewal cycle to lapse.
* A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that
* never took effect, and restricting an account over it would punish the wrong thing.
*/
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn "
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
@@ -0,0 +1,106 @@
/**
* 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.client.domain;
/**
* How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed.
*
* <p>
* RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out.
* The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be
* closed merely because periodic KYC is pending, and that any restriction follows notice and a fair
* opportunity to comply. The ladder therefore leaves the account untouched for a grace period,
* stops debits before it stops anything else, and never reaches closure on its own — closure stays
* a manual decision with its own notice.
*
* <p>
* The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate
* notice" without fixing a number — so they are declared here once and read from here everywhere.
*/
public enum KycExpiryStage {
/**
* Inside the grace window. Nothing is restricted; the customer is being chased.
*/
GRACE(0),
/**
* Debits stopped, credits still accepted. The first actual restriction, and the one that brings
* the customer into the branch without putting their incoming money at risk.
*/
PARTIAL_FREEZE(90),
/**
* Both directions restricted, barring the credits that must always be let through.
*/
INOPERATIVE(180);
/** Days past the expiry date at which this stage begins. */
private final int fromDaysOverdue;
KycExpiryStage(final int fromDaysOverdue) {
this.fromDaysOverdue = fromDaysOverdue;
}
public int getFromDaysOverdue() {
return fromDaysOverdue;
}
/**
* The stage an account sits at, given how far past its re-KYC due date it is.
*
* @param daysOverdue
* days since the expiry date; zero or negative means the KYC has not lapsed
* @return the stage, or {@code null} where the KYC is still current and nothing applies
*/
public static KycExpiryStage fromDaysOverdue(final long daysOverdue) {
if (daysOverdue < 0) {
return null;
}
if (daysOverdue >= INOPERATIVE.fromDaysOverdue) {
return INOPERATIVE;
}
if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) {
return PARTIAL_FREEZE;
}
return GRACE;
}
public boolean isGrace() {
return this == GRACE;
}
public boolean isPartialFreeze() {
return this == PARTIAL_FREEZE;
}
public boolean isInoperative() {
return this == INOPERATIVE;
}
/** Whether debits should be stopped at this stage. */
public boolean blocksDebits() {
return this == PARTIAL_FREEZE || this == INOPERATIVE;
}
/** Whether ordinary credits should be stopped at this stage. */
public boolean blocksCredits() {
return this == INOPERATIVE;
}
}
@@ -258,7 +258,6 @@ public class SavingsApiConstants {
public static final String neftFacilityParamName = "neftFacility";
public static final String rtgsFacilityParamName = "rtgsFacility";
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
public static final String withdrawalFacilityParamName = "withdrawalFacility";
public static final String depositFacilityKycParamName = "depositFacilityKyc";
public static final String virtualDebitCardParamName = "virtualDebitCard";
@@ -77,9 +77,6 @@ public class CurrentAccountFacilityConfig extends AbstractPersistableCustom<Long
@Column(name = "inter_bank_total_cap_facility", nullable = false)
private boolean interBankTotalCapFacility = false;
@Column(name = "withdrawal_facility", nullable = false)
private boolean withdrawalFacility = false;
@Column(name = "deposit_facility_kyc", nullable = false)
private boolean depositFacilityKyc = false;
@@ -120,7 +117,6 @@ public class CurrentAccountFacilityConfig 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());
if (config.has("internetBanking")) setInternetBanking(config.get("internetBanking").getAsBoolean());
@@ -136,9 +136,6 @@ public class CurrentAccountPendingEdit 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;
@@ -378,7 +378,6 @@ public class CurrentProductAssembler {
config.setNeftFacility(facilityConfigJson.has("neftFacility") ? facilityConfigJson.get("neftFacility").getAsBoolean() : false);
config.setRtgsFacility(facilityConfigJson.has("rtgsFacility") ? 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") ? facilityConfigJson.get("virtualDebitCard").getAsBoolean() : false);
return config;
@@ -75,9 +75,6 @@ public class CurrentProductFacilityConfig 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;
@@ -125,8 +122,6 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
public void setRtgsFacility(Boolean v) { this.rtgsFacility = v != null ? v : false; }
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; }
public Boolean getVirtualDebitCard() { return virtualDebitCard; }
@@ -147,7 +142,6 @@ public class CurrentProductFacilityConfig 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();
}
@@ -117,7 +117,6 @@ public class CurrentProductReadPlatformServiceImpl implements CurrentProductRead
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility());
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
product.setAccountFacilityConfig(facilityConfigMap);
@@ -64,9 +64,13 @@ public class ClientRiskEscalationServiceImpl implements ClientRiskEscalationServ
return;
}
// Moves the re-KYC due date onto the new category's cycle as well, so the customer is
// called back in on the schedule the escalated rating requires.
kyc.escalateRiskCategory(nextCategory);
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
context.authenticatedUser(), LocalDateTime.now()));
log.info("Client {} auto-escalated from risk category {} to {} on the {}rd cumulative STR case; re-KYC now due {}",
client.getId(), previousCategory, nextCategory, cumulativeCases, kyc.getExpiryDate());
}
private Integer nextCategory(Integer currentCategory) {
@@ -664,7 +664,13 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// The deposit register raised its own voucher at submission and attaches the till and
// member sides to it on approval, so the savings command must not raise a second one over
// the same money -- exactly as the GL branch of this register already does below. Without
// this, one deposit appears twice on the cashier's received report and doubles the day's
// cash taken in.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
@@ -740,7 +746,11 @@ public class CashierDepositRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// Suppressed for the same reason as the savings path above. Current accounts raise no
// voucher of their own today, so this changes nothing now; it is here so that the day one
// is added, this register does not silently start double-counting again.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
@@ -13,6 +13,7 @@ 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.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
@@ -24,6 +25,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashreports")
@Component
@RequiredArgsConstructor
@Slf4j
@Produces({ MediaType.APPLICATION_JSON })
public class CashReportApiResource {
@@ -34,6 +36,24 @@ public class CashReportApiResource {
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
* Excludes reversed counter transactions from a cash figure.
*
* m_savings_txn_denomination has no reversal marker of its own -- the notes counted at the
* counter are recorded once and never revisited -- so a reversed deposit or withdrawal left its
* breakdown behind and went on inflating the drawer for good. Every other leg of the cash
* position (vault transfers, settlements, inter-cashier legs, exchanges) already guards against
* its own reversal flag; this is the same guard for the counter, read off the transaction the
* breakdown belongs to.
*
* NOT EXISTS rather than a join, so a breakdown whose transaction cannot be found -- the DD
* register keys its rows by its own request id, not a savings transaction -- is kept rather than
* silently dropped.
*/
private static final String COUNTER_NOT_REVERSED = "AND NOT EXISTS ("
+ " SELECT 1 FROM m_savings_account_transaction rt "
+ " WHERE rt.id = std.txn_id AND rt.is_reversed = true) ";
/**
* Day book — every voucher raised on one date, in one list, newest first.
*
@@ -72,7 +92,11 @@ public class CashReportApiResource {
// Cashier deposits — money in, so the credit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
SELECT r.voucher_no,
-- Same rule as the withdrawal register below: an approved deposit is cash in
-- the drawer, a pending one is a request nobody has taken money for yet.
CASE WHEN r.status IN ('APPROVED', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
r.submitted_at, r.actioned_at,
@@ -84,13 +108,31 @@ public class CashReportApiResource {
// Clerk withdrawals — money out, so the debit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
SELECT r.voucher_no,
-- Filed by what happened to the money, not by who typed the request. Once the
-- notes are handed over it is the cashier's drawer that is short, so the row
-- belongs in their section; while it is still pending or was rejected no cash
-- has moved and it is the clerk's outstanding request.
CASE WHEN r.status IN ('WITHDRAWN', 'COMPLETED') THEN 'CASHIER' ELSE 'CLERK' END AS section,
'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id,
COALESCE(paid.officer_username, r.clerk_name) AS handled_by,
r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_withdrawal_request r
""" + verifyJoin + """
-- A withdrawal is raised by a clerk but the notes leave a cashier's drawer, so it
-- is filed under whoever's cash actually moved. Sectioning it by the maker put
-- every counter payment in the clerk's column and left the cashier's own section
-- short by exactly what they had paid out -- the one figure their drawer is
-- checked against. The payer is read from the denomination breakdown, which is
-- written when the cash is handed over, and the By column names them too.
LEFT JOIN LATERAL (
SELECT d.officer_username FROM m_savings_txn_denomination d
WHERE r.txn_id ~ '^[0-9]+$' AND d.txn_id = CAST(r.txn_id AS BIGINT)
AND d.officer_username IS NOT NULL
LIMIT 1) paid ON TRUE
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
@@ -125,7 +167,7 @@ public class CashReportApiResource {
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no,
SELECT r.voucher_no, 'CLERK' AS section,
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
@@ -137,6 +179,37 @@ public class CashReportApiResource {
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Cash taken at the counter for a Demand Draft — a receipt, so the credit side.
//
// The Sponsor DD register is not one of the three above and has no voucher_no of its own, so
// the day book showed nothing for it: a cashier who took ₹111.80 in notes for a draft saw a
// day book with an empty CR column and no sign of the money. The voucher is joined the other
// way round, from m_voucher.reference_id, which is where the link actually lives.
//
// Only the cash-funded drafts appear, and only those the cashier took the notes for
// directly: an account-debit or cheque draft never crosses the counter, and one funded from
// a cashier deposit request is already listed above under that register's own row.
rows.addAll(jdbcTemplate.query("""
SELECT v.voucher_no, 'CASHIER' AS section, 'CR' AS side, 'DD CASH' AS kind,
NULL AS account_no, 'GL' AS account_type,
COALESCE(r.walkin_purchaser_name, r.beneficiary_name) AS party,
r.face_value + COALESCE(r.charges_amount, 0) + COALESCE(r.gst_amount, 0) AS amount,
'CASH' AS payment_type_name,
CONCAT('DD for ', r.beneficiary_name) AS description,
r.status, mk.username AS handled_by, ck.username AS approved_by,
CAST(r.id AS VARCHAR) AS txn_id,
r.created_date AS submitted_at, r.checker_date AS actioned_at,
""" + verifyCols + """
FROM m_sponsor_dd_issue_request r
LEFT JOIN m_appuser mk ON mk.id = r.maker_id
LEFT JOIN m_appuser ck ON ck.id = r.checker_id
LEFT JOIN m_voucher v ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST'
AND v.reference_id = r.id AND v.voucher_no LIKE 'CR/%'
""" + verifyJoin.replace("r.voucher_no", "v.voucher_no") + """
WHERE r.payment_method = 'CASH' AND r.cashier_deposit_request_id IS NULL
AND COALESCE(r.issued_date, CAST(r.created_date AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
// ordered as one result set, and the row count for a single day is small.
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
@@ -195,6 +268,7 @@ public class CashReportApiResource {
result.put("date", txnDate);
result.put("rows", filtered);
result.put("count", filtered.size());
// Counter figures: the credit and debit the customer side of the day adds up to.
result.put("creditTotal", creditTotal);
result.put("debitTotal", debitTotal);
result.put("netTotal", creditTotal.subtract(debitTotal));
@@ -204,6 +278,10 @@ public class CashReportApiResource {
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getString("voucher_no"));
// Which part of the day's work this row belongs to. The registers do not divide along
// maker lines -- a clerk raises a withdrawal that a cashier pays out -- so the section says
// where the entry came from, not who typed it.
row.put("section", rs.getString("section"));
row.put("side", rs.getString("side"));
row.put("kind", rs.getString("kind"));
row.put("accountNo", rs.getString("account_no"));
@@ -241,12 +319,21 @@ public class CashReportApiResource {
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
return row;
}
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// A closing report for a cashier is that one drawer, so it is scoped the way `excess` and
// `cashierPosition` already are; a head cashier or admin gets null and still sees the branch.
final Long scopedCashierId = cashierRoleHelper.getCashierIdForFiltering();
// The predicate goes through m_appuser because m_savings_txn_denomination records the acting
// user in officer_id, not the m_cashiers row -- the same join the position query uses.
final String cashierFilter = scopedCashierId != null ? "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
@@ -255,10 +342,16 @@ public class CashReportApiResource {
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_date = CAST(? AS DATE) "
+ "WHERE std.txn_date = CAST(? AS DATE) " + cashierFilter + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
+ "ORDER BY std.denomination_value DESC, dm.kind";
final List<Object> args = new ArrayList<>();
args.add(txnDate);
if (scopedCashierId != null) {
args.add(scopedCashierId);
}
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
long depositCount = rs.getLong("deposit_count");
long withdrawalCount = rs.getLong("withdrawal_count");
@@ -275,7 +368,7 @@ public class CashReportApiResource {
row.put("netCount", depositCount - withdrawalCount);
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
return row;
}, txnDate);
}, args.toArray());
BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO;
@@ -285,6 +378,7 @@ public class CashReportApiResource {
}
// Cash exchange rows — separate section (denomination swap, net monetary = 0)
final String exchangeCashierFilter = scopedCashierId != null ? "AND ce.cashier_id = ? " : "";
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
@@ -294,10 +388,16 @@ public class CashReportApiResource {
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.exchange_date = CAST(? AS DATE) "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) " + exchangeCashierFilter
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
+ "ORDER BY ced.denomination_value DESC, dm.kind";
final List<Object> exArgs = new ArrayList<>();
exArgs.add(txnDate);
if (scopedCashierId != null) {
exArgs.add(scopedCashierId);
}
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
long receivedCount = rs.getLong("received_count");
long givenCount = rs.getLong("given_count");
@@ -314,18 +414,96 @@ public class CashReportApiResource {
row.put("netCount", receivedCount - givenCount);
row.put("netAmount", receivedAmount.subtract(givenAmount));
return row;
}, txnDate);
}, exArgs.toArray());
// Cash taken over the counter for a Demand Draft.
//
// A cash DD asks for a denomination breakdown but does not insist on one, so most are
// recorded with none at all -- and the whole of this report is built from breakdowns. The
// money is real: the ledger holds it (Cash at Vault is debited when the notes are taken)
// and the cashier is holding the notes, yet the drawer figure omitted every rupee of it.
//
// Counted from the request itself rather than from a breakdown, so it appears whether or not
// the notes were itemised. Excluded are DDs funded through a cashier deposit request: that
// register counted the same notes already, and adding them here would count them twice.
final String ddCashierFilter = scopedCashierId != null ? "AND dd.maker_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " : "";
final String ddSql = "SELECT COALESCE(SUM(dd.face_value + COALESCE(dd.charges_amount, 0) "
+ "+ COALESCE(dd.gst_amount, 0)), 0) AS cash_total, COUNT(*) AS cash_count "
+ "FROM m_sponsor_dd_issue_request dd "
+ "WHERE dd.payment_method = 'CASH' AND dd.cashier_deposit_request_id IS NULL "
+ "AND dd.status <> 'REJECTED' "
+ "AND COALESCE(dd.issued_date, CAST(dd.created_date AS DATE)) = CAST(? AS DATE) " + ddCashierFilter;
final List<Object> ddArgs = new ArrayList<>();
ddArgs.add(txnDate);
if (scopedCashierId != null) {
ddArgs.add(scopedCashierId);
}
BigDecimal ddCashTotal = BigDecimal.ZERO;
long ddCashCount = 0;
try {
final Map<String, Object> ddRow = jdbcTemplate.queryForMap(ddSql, ddArgs.toArray());
ddCashTotal = (BigDecimal) ddRow.get("cash_total");
ddCashCount = ((Number) ddRow.get("cash_count")).longValue();
} catch (final RuntimeException e) {
// A tenant without the Sponsor DD tables still gets its closing report.
log.warn("Could not read cash Demand Draft receipts for {}", txnDate, e);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", rows);
// The denomination-wise total, which is what the rows below add up to.
result.put("depositTotal", depositTotal);
result.put("withdrawalTotal", withdrawalTotal);
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
// Reported separately as well, so a screen can show where the difference comes from rather
// than presenting a deposit total its own rows do not explain.
result.put("ddCashTotal", ddCashTotal);
result.put("ddCashCount", ddCashCount);
result.put("depositTotalWithDd", depositTotal.add(ddCashTotal));
result.put("netTotal", depositTotal.add(ddCashTotal).subtract(withdrawalTotal));
result.put("exchangeRows", exchangeRows);
result.put("cashierId", scopedCashierId);
result.put("scopedToCashier", scopedCashierId != null);
// Opening and closing cash in the drawer.
//
// netTotal above is only the day's counter movement, which is not what the cashier is
// holding. A closing figure has to carry the previous balance forward, so it is derived from
// the cumulative position -- vault allocations, settlements, inter-cashier legs and
// exchanges as well as the counter work -- read once as of the day before and once as of the
// day itself. The gap between the two is the day's entire cash movement, a wider figure than
// deposits minus withdrawals, which is why both are reported rather than one derived from
// the other: when they differ, the difference is vault and inter-cashier traffic.
//
// Only a cashier-scoped call can answer this. A branch-wide opening balance belongs to the
// branch day register, not to this report.
if (scopedCashierId != null) {
final BigDecimal openingBalance = positionTotalAsOf(scopedCashierId,
LocalDate.parse(txnDate).minusDays(1).toString());
final BigDecimal closingBalance = positionTotalAsOf(scopedCashierId, txnDate);
result.put("openingBalance", openingBalance);
result.put("closingBalance", closingBalance);
result.put("cashMovement", closingBalance.subtract(openingBalance));
}
return gson.toJson(result);
}
/**
* What one cashier is holding at the end of a given date, in rupees.
*
* Reuses {@link #buildPositionForCashier} rather than re-summing the eight legs, so the closing
* report and the position screen can never disagree about the same drawer.
*/
private BigDecimal positionTotalAsOf(Long cashierId, String asOfDate) {
final Map<String, Object> position = buildPositionForCashier(cashierId, asOfDate);
final Object total = position.get("totalAmount");
return total instanceof BigDecimal ? (BigDecimal) total : BigDecimal.ZERO;
}
@GET
@Path("excess")
public String excess(@QueryParam("threshold") BigDecimal threshold,
@@ -672,7 +850,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Settlements back to vault
@@ -698,7 +876,7 @@ public class CashReportApiResource {
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) " + COUNTER_NOT_REVERSED
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
@@ -22,6 +22,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -31,6 +32,8 @@ import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
@@ -41,6 +44,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashsettlement")
@Component
@RequiredArgsConstructor
@Slf4j
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashSettlementApiResource {
@@ -52,6 +56,7 @@ public class CashSettlementApiResource {
private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate;
private final CashierRoleHelper cashierRoleHelper;
private final VoucherService voucherService;
private final Gson gson = new Gson();
@POST
@@ -788,7 +788,13 @@ public class ClerkWithdrawalRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// The withdrawal register raised its own voucher at submission and attaches the member and
// till sides to it on approval, so the savings command must not raise a second one over the
// same money -- one withdrawal was appearing twice on the cashier's payment report, once
// under the register's number and once under the savings transaction's, doubling the day's
// cash paid out.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
@@ -816,7 +822,11 @@ public class ClerkWithdrawalRequestApiResource {
.withJson(payload.toString())
.build();
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest);
// Suppressed for the same reason as the savings path above. Current accounts raise no
// voucher of their own today, so this changes nothing now; it is here so that the day one
// is added, this register does not silently start double-counting again.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
@@ -70,4 +70,23 @@ public class ClientKycExceptionsApiResource {
}
return this.toApiJsonSerializer.serialize(exceptions);
}
/**
* Customers whose periodic re-KYC has already fallen due.
*
* <p>
* Sits under the exceptions path because it is the same worklist to the branch — a customer the
* bank has to reach — and the outreach is recorded against the same contact log.
*/
@GET
@Path("lapsed")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve lapsed KYC records", description = "Returns clients whose periodic re-KYC due date has passed, oldest first.")
public String retrieveLapsedKyc() {
this.context.authenticatedUser();
final List<ClientKycData> lapsed = this.readPlatformService.retrieveLapsedKyc();
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
return this.toApiJsonSerializer.serialize(lapsed);
}
}
@@ -0,0 +1,52 @@
/**
* 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.client.jobs.applykycexpiryrestrictions;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ApplyKycExpiryRestrictionsConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step applyKycExpiryRestrictionsStep(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) {
return new StepBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository)
.tasklet(applyKycExpiryRestrictionsTasklet, transactionManager).build();
}
@Bean
public Job applyKycExpiryRestrictionsJob(ApplyKycExpiryRestrictionsTasklet applyKycExpiryRestrictionsTasklet) {
return new JobBuilder(JobName.APPLY_KYC_EXPIRY_RESTRICTIONS.name(), jobRepository)
.start(applyKycExpiryRestrictionsStep(applyKycExpiryRestrictionsTasklet)).incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,46 @@
/**
* 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.client.jobs.applykycexpiryrestrictions;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.client.service.KycExpiryRestrictionService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
/**
* Nightly pass over customers whose periodic re-KYC has lapsed, applying the restriction each one
* has reached and releasing those whose KYC has since been renewed.
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class ApplyKycExpiryRestrictionsTasklet implements Tasklet {
private final KycExpiryRestrictionService kycExpiryRestrictionService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
kycExpiryRestrictionService.applyRestrictions();
return RepeatStatus.FINISHED;
}
}
@@ -35,4 +35,14 @@ public interface ClientKycReadPlatformService {
java.util.List<ClientKycData> retrieveActiveKycExceptions();
java.util.List<ClientKycData> retrieveResolvedKycExceptions();
/**
* KYC records whose periodic re-KYC due date has already passed, oldest first.
*
* <p>
* These are the customers the branch has to chase and, once the grace period runs out, the
* accounts the restriction ladder acts on. A record whose renewal is still ahead is not
* included: nothing is owed on it yet.
*/
java.util.List<ClientKycData> retrieveLapsedKyc();
}
@@ -25,6 +25,7 @@ import org.apache.fineract.portfolio.client.data.ClientKycData;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
import org.apache.fineract.portfolio.client.domain.ClientKycType;
import org.apache.fineract.portfolio.client.domain.KycRiskCategory;
@@ -159,4 +160,12 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
@Override
public java.util.List<ClientKycData> retrieveLapsedKyc() {
return this.kycRepository
.findLapsedAsOn(DateUtils.getBusinessLocalDate(), ClientKycStatus.REJECTED.getValue()).stream()
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
}
@@ -0,0 +1,49 @@
/**
* 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.client.service;
/**
* Applies and lifts the account restrictions that follow a lapsed periodic re-KYC.
*/
public interface KycExpiryRestrictionService {
/**
* Walks every lapsed KYC, applies the stage each one has reached, and releases customers whose
* KYC is current again.
*
* @return what the run did, for the job log
*/
KycExpiryRunSummary applyRestrictions();
/**
* What one run of the ladder did.
*
* @param lapsed
* KYC records past their due date
* @param inGrace
* of those, the ones still inside the grace window with nothing restricted
* @param debitBlocked
* customers whose debits are stopped
* @param fullyBlocked
* customers whose accounts are inoperative
* @param released
* accounts freed because the KYC was renewed
*/
record KycExpiryRunSummary(int lapsed, int inGrace, int debitBlocked, int fullyBlocked, int released) {}
}
@@ -0,0 +1,134 @@
/**
* 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.client.service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycExpiryRestrictionService.KycExpiryRunSummary;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Applies the restriction ladder that follows a lapsed periodic re-KYC, and lifts it once the KYC
* is renewed.
*
* <p>
* The ladder is described on {@link KycExpiryStage}. In short: nothing happens for the grace period,
* then debits stop, then the account goes inoperative. Closure is never reached automatically.
*
* <p>
* Restrictions are derived from the expiry date on every run rather than remembered, so a KYC that
* is renewed simply stops being lapsed and its accounts are released on the next pass. That also
* makes the job safe to re-run: applying a stage that is already applied changes nothing.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KycExpiryRestrictionServiceImpl implements KycExpiryRestrictionService {
private final ClientKycRepository clientKycRepository;
private final SavingsAccountRepository savingsAccountRepository;
@Override
@Transactional
public KycExpiryRunSummary applyRestrictions() {
final LocalDate today = DateUtils.getBusinessLocalDate();
final List<ClientKyc> lapsed = clientKycRepository.findLapsedAsOn(today, ClientKycStatus.REJECTED.getValue());
int debitBlocked = 0;
int fullyBlocked = 0;
int inGrace = 0;
for (final ClientKyc kyc : lapsed) {
final long daysOverdue = ChronoUnit.DAYS.between(kyc.getExpiryDate(), today);
final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue);
if (stage == null || stage.isGrace()) {
// Inside the grace window nothing is restricted; the customer is only being chased.
inGrace++;
continue;
}
final Long clientId = kyc.getClient().getId();
final List<SavingsAccount> accounts = savingsAccountRepository.findSavingAccountByClientId(clientId);
boolean changedAny = false;
for (final SavingsAccount account : accounts) {
final boolean changed = stage.isInoperative() ? account.applyKycExpiryFullBlock() : account.applyKycExpiryDebitBlock();
if (changed) {
savingsAccountRepository.save(account);
changedAny = true;
}
}
if (stage.isInoperative()) {
fullyBlocked++;
} else {
debitBlocked++;
}
if (changedAny) {
log.info("Client {} re-KYC lapsed {} days ago — {} applied to their savings accounts", clientId, daysOverdue, stage);
}
}
final int released = releaseRenewed(today);
final KycExpiryRunSummary summary = new KycExpiryRunSummary(lapsed.size(), inGrace, debitBlocked, fullyBlocked, released);
log.info("KYC expiry restrictions applied: {}", summary);
return summary;
}
/**
* Lifts restrictions from customers whose KYC is current again.
*
* <p>
* Renewing a KYC pushes its expiry date into the future, so a renewed customer simply drops out
* of the lapsed set. Nothing records that this job was the one that blocked the account, so only
* the two states the ladder itself sets are cleared — see
* {@link SavingsAccount#releaseKycExpiryBlock()} — and a manual block is left untouched.
*/
private int releaseRenewed(final LocalDate today) {
final List<ClientKyc> current = clientKycRepository.findAllByStatus(ClientKycStatus.VERIFIED.getValue());
int released = 0;
for (final ClientKyc kyc : current) {
if (kyc.getExpiryDate() == null || !kyc.getExpiryDate().isAfter(today)) {
continue;
}
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(kyc.getClient().getId())) {
if (account.releaseKycExpiryBlock()) {
savingsAccountRepository.save(account);
released++;
log.info("Client {} re-KYC renewed to {} — restrictions lifted on savings account {}", kyc.getClient().getId(),
kyc.getExpiryDate(), account.getId());
}
}
}
return released;
}
}
@@ -0,0 +1,80 @@
/**
* 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.api;
import com.google.gson.JsonElement;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.savings.service.DeafRemittanceWritePlatformService;
import org.springframework.stereotype.Component;
/**
* Records remittances of escheated balances to RBI's Depositor Education and Awareness Fund.
*
* <p>
* The transfer to RBI happens outside this system; these endpoints only write down that it was
* made, so the escheat report can distinguish money that reached the fund from money that has left
* the customer's account and gone nowhere.
*/
@Path("/v1/savingsaccounts/deaf-remittance")
@Component
@Tag(name = "DEAF Remittance", description = "Record remittance of escheated balances to RBI's DEAF.")
@RequiredArgsConstructor
public class DeafRemittanceApiResource {
private final DeafRemittanceWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
private final FromJsonHelper fromJsonHelper;
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record a DEAF remittance", description = "Marks the escheated balances of the given savings accounts "
+ "as remitted to RBI. Accounts that are not escheated, or already recorded, are skipped.")
public String recordRemittance(final String apiRequestBodyAsJson) {
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonCommand command = JsonCommand.fromJsonElement(null, parsed, this.fromJsonHelper);
final CommandProcessingResult result = this.writePlatformService.recordRemittance(command);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{savingsAccountId}/refund")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record a claim against a remitted balance", description = "Marks that the customer claimed the balance "
+ "back after it had gone to the fund, and the bank recovered it from RBI.")
public String markRefunded(@PathParam("savingsAccountId") final Long savingsAccountId, final String apiRequestBodyAsJson) {
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonCommand command = JsonCommand.fromJsonElement(savingsAccountId, parsed, this.fromJsonHelper);
final CommandProcessingResult result = this.writePlatformService.markRefunded(savingsAccountId, command);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -0,0 +1,59 @@
/**
* 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.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
import org.springframework.stereotype.Component;
/**
* The read side of the dormancy ladder: which accounts the nightly job has marked, and how long
* each has been idle.
*/
@Path("/v1/savingsaccounts/dormant")
@Component
@Tag(name = "Dormant Savings Accounts", description = "Accounts marked inactive, dormant or escheated by the dormancy job.")
@RequiredArgsConstructor
public class DormantAccountsApiResource {
private final DormantAccountReadPlatformService readPlatformService;
private final DefaultToApiJsonSerializer<DormantAccountData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve dormant accounts", description = "Accounts at any of the three dormancy marks, longest idle first. "
+ "Pass subStatus to narrow to one: 100 inactive, 200 dormant, 300 escheat.")
public String retrieveDormantAccounts(@QueryParam("subStatus") final Integer subStatus,
@QueryParam("officeName") final String officeName) {
final List<DormantAccountData> accounts = this.readPlatformService.retrieveDormantAccounts(subStatus, officeName);
return this.toApiJsonSerializer.serialize(accounts);
}
}
@@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
@@ -50,6 +51,7 @@ public class SavingsDailyInterestOrchestrator {
/** Accrue for the day, then post for any product whose cycle ends today. */
public void runForDate(final LocalDate businessDate) {
accrualService.accrueForDate(businessDate);
enrolNewProducts();
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
SELECT s.savings_product_id, s.posting_period_enum
@@ -88,6 +90,66 @@ public class SavingsDailyInterestOrchestrator {
}
}
/**
* Enrols the product behind one account, if it is not already on the timetable.
*
* <p>
* Called when an account is activated, so the first account opened on a brand-new product brings
* that product onto the timetable rather than the product waiting for the nightly sweep. The
* insert is guarded the same way as {@link #enrolNewProducts()}, so calling it for a product
* already enrolled does nothing.
*/
@Transactional
public void enrolProductOf(final Long savingsAccountId) {
try {
final int enrolled = jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_schedule
(savings_product_id, posting_period_enum, run_at_time, is_active)
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
AND sp.deposit_type_enum = 100
AND sp.interest_posting_period_enum IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
WHERE s.savings_product_id = sp.id)
""", savingsAccountId);
if (enrolled > 0) {
log.info("Savings interest: enrolled the product behind account {} into the daily-interest schedule", savingsAccountId);
}
} catch (final RuntimeException e) {
// Called from activation, which must not be rolled back because the timetable could not
// be written. The nightly sweep enrols the product anyway.
log.error("Savings interest: could not enrol the product behind account {}; the nightly run will do it",
savingsAccountId, e);
}
}
/**
* The nightly sweep: gives any savings product without a timetable one, on its own posting period.
*
* <p>
* Activation enrols a product as soon as its first account opens, so this is the safety net —
* it catches a product whose accounts were activated while this was not yet wired, or where the
* enrolment on activation failed. The period comes from the product's own
* {@code interest_posting_period_enum}, so a product set to post monthly posts monthly.
*/
private void enrolNewProducts() {
final int enrolled = jdbcTemplate.update("""
INSERT INTO m_savings_interest_posting_schedule
(savings_product_id, posting_period_enum, run_at_time, is_active)
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
FROM m_savings_product sp
WHERE sp.deposit_type_enum = 100
AND sp.interest_posting_period_enum IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
WHERE s.savings_product_id = sp.id)
""");
if (enrolled > 0) {
log.info("Savings interest: enrolled {} new savings product(s) into the daily-interest schedule", enrolled);
}
}
/**
* Moves a product's timetable on. next_posting_date is worked out from the cycle the ledger
* itself recorded, so the schedule and the rows can never drift apart.
@@ -118,6 +118,62 @@ public class SavingsDailyInterestService {
return written;
}
/**
* Opens the ledger for one newly activated account.
*
* <p>
* The nightly run would pick the account up on its own, but not until tomorrow, and until then
* the account is simply absent from the ledger — which reads as though it is not being accrued at
* all. Called from activation, it writes the account's first row there and then, so an account is
* on the ledger from the day it opens.
*
* <p>
* Only savings accounts are accrued. Anything else — a term or recurring deposit — is left alone,
* and a failure is logged rather than raised: the activation itself has succeeded and must not be
* rolled back because the ledger could not be opened. The nightly run will pick it up.
*
* <p>
* Runs in the caller's transaction, deliberately. Activation sets the account active in that
* transaction and has not committed yet, so a REQUIRES_NEW transaction here would read the row as
* it was before — still approved, not yet active — find nothing to accrue, and return quietly
* having done nothing. Joining the caller sees the activation, and the status is not re-checked
* for the same reason: the value in the database is the pre-activation one until commit.
*
* @return true if a row was written
*/
@Transactional
public boolean accrueOnActivation(final Long savingsAccountId, final LocalDate businessDate) {
try {
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
SELECT sa.id, sa.product_id, sa.activatedon_date,
sp.nominal_annual_interest_rate AS rate,
sp.interest_posting_period_enum AS posting_period,
sp.interest_compounding_period_enum AS compounding_period,
sp.interest_calculation_type_enum AS calculation_type,
sp.interest_calculation_days_in_year_type_enum AS days_in_year,
sa.min_balance_for_interest_calculation AS min_balance
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ? AND sp.deposit_type_enum = ?
AND sa.activatedon_date IS NOT NULL AND sa.activatedon_date <= ?
""", savingsAccountId, SAVINGS_DEPOSIT_TYPE, businessDate);
if (accounts.isEmpty()) {
return false;
}
final boolean written = accrueOneAccount(accounts.get(0), businessDate, financialYearBeginningMonth());
if (written) {
log.info("Savings daily interest: opened the ledger for newly activated account {}", savingsAccountId);
}
return written;
} catch (final RuntimeException e) {
log.error("Savings daily interest: could not open the ledger for account {} on activation; the nightly run will "
+ "pick it up", savingsAccountId, e);
return false;
}
}
private boolean accrueOneAccount(final Map<String, Object> account, final LocalDate businessDate,
final int financialYearBeginningMonth) {
final Long accountId = ((Number) account.get("id")).longValue();
@@ -130,16 +186,19 @@ public class SavingsDailyInterestService {
final int daysInYear = daysInYear(account.get("days_in_year"), businessDate);
final BigDecimal minBalance = (BigDecimal) account.get("min_balance");
// The previous row is the only thing that carries the chain forward. Its absence means
// this is the account's first day; a gap would mean a day the job never ran, and rather
// than silently re-anchoring we take the last row we do have and let the reconciliation
// report the break.
// The account's row is the only thing that carries the chain forward: it holds the balance
// the last accrued day closed on and the cycle's running total. Its absence means this is
// the account's first day.
//
// The ledger keeps one row per account, so the row is read and then rewritten in place with
// today's figures. Only a row for a day before today can be carried forward — one already
// stamped with today or later means the day has been accrued and re-reading it as "previous"
// would compound the same day's interest onto itself.
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
SELECT balance_date, closing_balance, cycle_cumulative_interest, cycle_number,
cycle_start_date, cycle_end_date, is_posting_day
FROM m_savings_daily_interest
WHERE savings_account_id = ? AND balance_date < ?
ORDER BY balance_date DESC LIMIT 1
""", accountId, businessDate);
final BigDecimal openingBalance;
@@ -152,20 +211,30 @@ public class SavingsDailyInterestService {
openingBalance = BigDecimal.ZERO;
carriedInterest = BigDecimal.ZERO;
cycleNumber = 1;
// Cycle 1 is a stub: it starts when the account was activated, not on the grid.
cycleStart = activatedOn;
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(activatedOn, postingPeriod, financialYearBeginningMonth);
// The first row belongs to the cycle the day it records falls in, and starts at
// activation only when that day is still inside the account's opening cycle. Anchoring
// on activation alone dates the row into a cycle that closed months ago: an account
// opened in June, first accrued in September, would be stamped with June's cycle end
// and sit there permanently past its own posting date.
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
financialYearBeginningMonth);
final LocalDate gridStart = SavingsInterestCycleCalculator.cycleStartDate(businessDate, postingPeriod,
financialYearBeginningMonth);
cycleStart = activatedOn.isAfter(gridStart) ? activatedOn : gridStart;
} else {
final Map<String, Object> prev = previous.get(0);
openingBalance = (BigDecimal) prev.get("closing_balance");
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
if (prevWasPosting) {
// The cycle closed yesterday: the sum went to the member and starts again at zero.
// A cycle is finished either because it was paid out or because its end date has gone
// by. The second case happens whenever the ledger has a gap over a posting date - the
// run was missed, or the account was accrued again after a break - and treating it as
// unfinished would leave the row in a cycle that ended before the day it records.
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
carriedInterest = BigDecimal.ZERO;
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
cycleStart = prevCycleEnd.plusDays(1);
cycleEnd = SavingsInterestCycleCalculator.nextCycleEndDate(prevCycleEnd, postingPeriod,
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
financialYearBeginningMonth);
} else {
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
@@ -232,26 +301,36 @@ public class SavingsDailyInterestService {
posting_date, is_posting_day, interest_rate, days_in_year, compounding_period_enum,
posting_period_enum, calculation_type_enum, min_balance_for_interest, below_min_balance)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT (savings_account_id, balance_date) DO UPDATE SET
ON CONFLICT (savings_account_id) DO UPDATE SET
balance_date = EXCLUDED.balance_date,
opening_balance = EXCLUDED.opening_balance,
-- A day whose interest has already been paid keeps the balance it was left
-- with. Re-accruing that day recomputes the closing balance from
-- transactions alone, which does not include this ledger's own posting, and
-- writing it back would erase the credit from the chain: the next day would
-- open lower than the member's real balance and they would stop earning on
-- interest they have been given.
closing_balance = CASE
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
THEN EXCLUDED.closing_balance + m_savings_daily_interest.posted_amount
ELSE EXCLUDED.closing_balance END,
-- Taken as computed. The interest this ledger posts is counted as movement on
-- the day it was paid (see dayMovement), so the recomputed closing already
-- carries it and needs no adjustment here.
closing_balance = EXCLUDED.closing_balance,
daily_interest = EXCLUDED.daily_interest,
-- Likewise the total that was actually paid: once money has moved on the
-- strength of this figure, recomputing it would leave the ledger disagreeing
-- with the member's passbook. A posted day is settled.
-- strength of this figure, recomputing the same day would leave the ledger
-- disagreeing with the member's passbook. A posted day is settled — but only
-- that day. The next day starts the new cycle's total from zero, which is what
-- the accrual has computed, so it is taken as given.
cycle_cumulative_interest = CASE
WHEN m_savings_daily_interest.posted_amount IS NOT NULL
AND m_savings_daily_interest.balance_date = EXCLUDED.balance_date
THEN m_savings_daily_interest.cycle_cumulative_interest
ELSE EXCLUDED.cycle_cumulative_interest END,
-- Cleared when the row moves to a new day: posted_amount and the transaction
-- behind it belong to the day they were paid on, and carrying them onto the
-- next day would make an unposted cycle look settled.
posted_amount = CASE
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
THEN m_savings_daily_interest.posted_amount ELSE NULL END,
savings_transaction_id = CASE
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
THEN m_savings_daily_interest.savings_transaction_id ELSE NULL END,
posting_run_id = CASE
WHEN m_savings_daily_interest.balance_date = EXCLUDED.balance_date
THEN m_savings_daily_interest.posting_run_id ELSE NULL END,
cycle_number = EXCLUDED.cycle_number,
cycle_start_date = EXCLUDED.cycle_start_date,
cycle_end_date = EXCLUDED.cycle_end_date,
@@ -299,16 +378,21 @@ public class SavingsDailyInterestService {
}
/**
* The day's net movement, excluding interest postings.
* The day's net movement, interest postings included.
*
* Interest postings are left out on purpose. The posting is this ledger's own output, and
* folding it back in here would let it accrue interest on itself a second time. It is added to
* the closing balance by the posting run, on the posting day, once.
* Interest credited by this ledger counts as movement like any other credit. It has to: the
* ledger holds one row per account, so a balance is only ever built from the row before it, and
* a posting left out of the movement is a posting missing from every balance that follows. The
* member is paid, their passbook shows it, and the ledger would go on earning on the balance
* they had before - the interest silently stops compounding.
*
* It cannot double-count, because a posting only ever falls on one day and the row for that day
* keeps the balance the posting left it with (see the closing_balance guard in the upsert).
*/
private BigDecimal dayMovement(final Long accountId, final LocalDate businessDate) {
final BigDecimal movement = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE
WHEN t.transaction_type_enum IN (1, 8) THEN t.amount
WHEN t.transaction_type_enum IN (1, 3, 8) THEN t.amount
WHEN t.transaction_type_enum IN (2, 4, 5, 7) THEN -t.amount
ELSE 0 END), 0)
FROM m_savings_account_transaction t
@@ -40,11 +40,24 @@ import org.springframework.transaction.annotation.Transactional;
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
* holds the figure that would say so.
*
* This posts the product's total first, into Savings Interest Posting Control, moves it to Savings
* Interest Posted Control, and only then pays it out to members. The whole product settles inside
* one transaction, and both control accounts have to read zero before it is allowed to commit. A
* failure rolls the product back entirely - nobody is half paid - and a run that never reached
* commit leaves a PENDING row naming the product and date for the next run to pick up.
* This posts the product's total first, into the liability the product names as its Savings Interest
* Payable, and only then pays it out to members:
*
* <pre>
* Dr Savings Interest Paid / Interest On Savings (expense, from the product)
* Cr Savings Interest Payable (liability, from the product)
*
* Dr Savings Interest Payable (per member)
* Cr Savings Control (per member, from the product)
* </pre>
*
* Every account named above is read from the product's own GL configuration; none is written into
* this class. A product that configures no payable falls back to the two seeded control accounts,
* which keeps products predating that mapping posting exactly as before.
*
* The whole product settles inside one transaction, and the payable has to read zero before it is
* allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run
* that never reached commit leaves a PENDING row naming the product and date for the next run.
*
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
@@ -61,6 +74,15 @@ public class SavingsPooledInterestPostingService {
private static final int MONEY_SCALE = 2;
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
/**
* Reserved names in the product's own GL mappings, which is where the two legs of the interest
* bill are configured. The names are the contract between this service and the product screen —
* the GL codes behind them are the bank's choice, not ours, so nothing here names an account.
*/
private static final String MAPPING_INTEREST_PAYABLE = "Savings Interest Payable";
private static final String MAPPING_INTEREST_PAID = "Savings Interest Paid";
/** Fallback control accounts, used only where the product leaves the mappings unset. */
private static final String GL_POSTING_CONTROL = "200304";
private static final String GL_POSTED_CONTROL = "200305";
@@ -142,20 +164,34 @@ public class SavingsPooledInterestPostingService {
largest.get("savings_account_id"));
}
final Long postingControlGl = glAccountId(GL_POSTING_CONTROL);
final Long postedControlGl = glAccountId(GL_POSTED_CONTROL);
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS);
// The expense the bill is recognised against, and the liability it sits in until it reaches
// the members. Both come from the product: "Savings Interest Paid" if the product names one,
// otherwise the standard Interest On Savings mapping; "Savings Interest Payable" for the
// liability, falling back to the control accounts only where a product configures neither.
final Long interestExpenseGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID),
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS));
final Long payableGl = namedGlAccount(productId, MAPPING_INTEREST_PAYABLE);
final Long postingControlGl = firstNonNull(payableGl, glAccountId(GL_POSTING_CONTROL));
final Long postedControlGl = payableGl != null ? payableGl : glAccountId(GL_POSTED_CONTROL);
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
final String currency = (String) due.get(0).get("currency_code");
final String poolReference = "SIP-" + productId + "-" + postingDate;
// Stage 1: the product's whole interest bill, recognised and pooled.
// Stage 1: the product's whole interest bill, recognised and parked in the payable.
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, false, poolReference, null);
// Stage 2: the pool moves to the account it is paid out of.
//
// Where the product names a single payable, both control legs resolve to it and this stage
// would debit and credit the same account for the same amount — a pair of entries that say
// nothing and clutter the ledger. Only a product still on the two distinct control accounts
// needs the transfer written.
if (!postingControlGl.equals(postedControlGl)) {
journalEntry(officeId, currency, postingControlGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, postedControlGl, postingDate, pooled, false, poolReference, null);
}
// Stage 3: out to the members, one at a time.
BigDecimal distributed = BigDecimal.ZERO;
@@ -177,6 +213,9 @@ public class SavingsPooledInterestPostingService {
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
accountId, transactionId);
// closing_balance is raised here because the interest transaction has just been written
// and the accrual for this day has already run: nothing else will fold it in before the
// next day reads this row as its opening.
jdbcTemplate.update("""
UPDATE m_savings_daily_interest
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
@@ -297,4 +336,48 @@ public class SavingsPooledInterestPostingService {
LIMIT 1
""", Long.class, productId, financialAccountType);
}
/**
* The GL account a product has mapped under one of the reserved names in its own GL mappings.
*
* <p>
* These live in {@code m_savings_product.other_gl_mappings} as a JSON document of
* {@code {assets|liabilities|expenses|income: [{accountName, glCode}]}} rather than in
* {@code acc_product_mapping}, because they are named extensions rather than one of Fineract's
* fixed financial account types. The name is matched across all four groups: which group a bank
* files it under is a presentation choice, and a payable configured under the wrong heading
* should still be honoured rather than silently ignored.
*
* @return the account id, or null where the product does not configure this name
*/
private Long namedGlAccount(final long productId, final String accountName) {
// The column holds an empty string on a product saved with no named mappings, and casting
// that to jsonb is an error rather than an empty document — so the cast is done inside a
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
// clause in any order, so guarding in the same query level as the cast would only work by
// luck.
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE m.entry ->> 'accountName' = ?
LIMIT 1
""", Long.class, productId, accountName);
return found.isEmpty() ? null : found.get(0);
}
private static Long firstNonNull(final Long preferred, final Long fallback) {
return preferred != null ? preferred : fallback;
}
}
@@ -0,0 +1,194 @@
/**
* 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.dailyinterest;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Pushes a savings product's interest terms down onto the accounts opened under it.
*
* Fineract copies rate, posting period, compounding period, calculation type and days-in-year onto
* each account when it is created, and the interest engine reads that copy from then on. Editing the
* product changes nothing for accounts that already exist: a product moved from 7% to 10% goes on
* paying 7% on every account opened before the edit, indefinitely and silently. Because the copies
* are what the engine reads, they are also what the member is actually paid.
*
* This bank's rule is that interest follows the product, so an edit has to reach the accounts. The
* cascade runs on the same transaction as the product update, which means an edit either lands
* everywhere or nowhere - no state where the product says one thing and its accounts another.
*
* Scope is deliberately narrow:
*
* - savings only (deposit_type_enum 100). Fixed and recurring deposits keep the rate they were
* contracted at; that is the nature of a term deposit and changing it retroactively would
* rewrite an agreement.
* - active and pending accounts only. A closed account's terms are history.
* - min_balance_for_interest_calculation is never touched: it exists only on the account, so
* there is nothing on the product to cascade from.
*
* Interest already posted is not disturbed. Only future accrual moves, which is what an interest
* rate change ordinarily means.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class SavingsProductTermsCascadeService {
private static final int SAVINGS_DEPOSIT_TYPE = 100;
/** m_savings_account.status_enum: 100 submitted/pending approval, 200 approved, 300 active. */
private static final String CASCADEABLE_STATUSES = "(100, 200, 300)";
private final JdbcTemplate jdbcTemplate;
/**
* Copies the product's interest terms onto its savings accounts.
*
* @param productId
* the product that was edited
* @param changes
* what the update actually changed, from the command result; the cascade is skipped
* when none of the interest fields are among them
* @return how many accounts were updated
*/
@Transactional
public int cascadeInterestTerms(final Long productId, final Map<String, Object> changes) {
if (productId == null || changes == null || changes.isEmpty()) {
return 0;
}
if (!touchesInterestTerms(changes)) {
return 0;
}
final List<Map<String, Object>> product = jdbcTemplate.queryForList("""
SELECT nominal_annual_interest_rate, interest_compounding_period_enum,
interest_posting_period_enum, interest_calculation_type_enum,
interest_calculation_days_in_year_type_enum
FROM m_savings_product
WHERE id = ? AND deposit_type_enum = ?
""", productId, SAVINGS_DEPOSIT_TYPE);
if (product.isEmpty()) {
// Not a savings product - a fixed or recurring deposit, which is left alone.
return 0;
}
// Only rows that actually differ are written, so the count reported is the number of
// accounts whose terms really moved rather than the size of the product's book.
final int updated = jdbcTemplate.update("""
UPDATE m_savings_account sa
SET nominal_annual_interest_rate = sp.nominal_annual_interest_rate,
interest_compounding_period_enum = sp.interest_compounding_period_enum,
interest_posting_period_enum = sp.interest_posting_period_enum,
interest_calculation_type_enum = sp.interest_calculation_type_enum,
interest_calculation_days_in_year_type_enum = sp.interest_calculation_days_in_year_type_enum
FROM m_savings_product sp
WHERE sp.id = sa.product_id
AND sa.product_id = ?
AND sp.deposit_type_enum = ?
AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
OR sa.interest_calculation_days_in_year_type_enum
<> sp.interest_calculation_days_in_year_type_enum)
""", productId, SAVINGS_DEPOSIT_TYPE);
if (updated > 0) {
log.info("Savings product {}: interest terms cascaded to {} account(s) — {}", productId, updated,
changes.keySet());
}
return updated;
}
/**
* Whether the edit touched anything the accounts hold a copy of.
*
* The names are the API's own parameter names, which is what the update result reports.
*/
private boolean touchesInterestTerms(final Map<String, Object> changes) {
return changes.containsKey("nominalAnnualInterestRate")
|| changes.containsKey("interestCompoundingPeriodType")
|| changes.containsKey("interestPostingPeriodType")
|| changes.containsKey("interestCalculationType")
|| changes.containsKey("interestCalculationDaysInYearType");
}
/**
* Brings every savings account in line with its product, regardless of what changed.
*
* For the accounts that drifted before the cascade existed - in this tenant, accounts opened on
* a 7% daily copy of a product that now reads 10% quarterly.
*
* @return how many accounts were updated
*/
@Transactional
public int realignAllAccounts() {
final int updated = jdbcTemplate.update("""
UPDATE m_savings_account sa
SET nominal_annual_interest_rate = sp.nominal_annual_interest_rate,
interest_compounding_period_enum = sp.interest_compounding_period_enum,
interest_posting_period_enum = sp.interest_posting_period_enum,
interest_calculation_type_enum = sp.interest_calculation_type_enum,
interest_calculation_days_in_year_type_enum = sp.interest_calculation_days_in_year_type_enum
FROM m_savings_product sp
WHERE sp.id = sa.product_id
AND sp.deposit_type_enum = ?
AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
OR sa.interest_calculation_days_in_year_type_enum
<> sp.interest_calculation_days_in_year_type_enum)
""", SAVINGS_DEPOSIT_TYPE);
log.info("Savings interest terms realigned to product on {} account(s)", updated);
return updated;
}
/** The accounts whose terms differ from their product, for a report or a pre-flight check. */
public List<Map<String, Object>> findDrifted() {
return jdbcTemplate.queryForList("""
SELECT sa.id AS account_id, sa.account_no, sp.id AS product_id, sp.name AS product_name,
sa.nominal_annual_interest_rate AS account_rate,
sp.nominal_annual_interest_rate AS product_rate,
sa.interest_posting_period_enum AS account_posting,
sp.interest_posting_period_enum AS product_posting,
sa.interest_calculation_type_enum AS account_calculation,
sp.interest_calculation_type_enum AS product_calculation
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sp.deposit_type_enum = ?
AND sa.status_enum IN """ + CASCADEABLE_STATUSES + """
AND (sa.nominal_annual_interest_rate <> sp.nominal_annual_interest_rate
OR sa.interest_compounding_period_enum <> sp.interest_compounding_period_enum
OR sa.interest_posting_period_enum <> sp.interest_posting_period_enum
OR sa.interest_calculation_type_enum <> sp.interest_calculation_type_enum
OR sa.interest_calculation_days_in_year_type_enum
<> sp.interest_calculation_days_in_year_type_enum)
ORDER BY sp.id, sa.id
""", SAVINGS_DEPOSIT_TYPE);
}
}
@@ -34,6 +34,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@@ -53,8 +54,35 @@ public class SavingsDailyInterestApiResource {
private final JdbcTemplate jdbcTemplate;
private final SavingsDailyInterestService accrualService;
private final SavingsDailyInterestOrchestrator orchestrator;
private final SavingsProductTermsCascadeService termsCascadeService;
private final Gson gson = new Gson();
/**
* The accounts whose interest terms differ from the product they were opened under.
*
* A product edit cascades to its accounts now, but accounts opened before that was so can still
* be sitting on the terms they were created with — and those copies are what the engine pays on.
*/
@GET
@Path("/drifted")
public String drifted() {
final List<Map<String, Object>> rows = termsCascadeService.findDrifted();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("count", rows.size());
result.put("accounts", rows);
return gson.toJson(result);
}
/** Brings every savings account's interest terms back in line with its product. */
@POST
@Path("/realign")
public String realign() {
final int updated = termsCascadeService.realignAllAccounts();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("accountsRealigned", updated);
return gson.toJson(result);
}
/** The savings products carrying a ledger, with where each one is in its timetable. */
@GET
@Path("/products")
@@ -117,6 +145,119 @@ public class SavingsDailyInterestApiResource {
return gson.toJson(rows);
}
/**
* One row per account: where each one stands right now.
*
* <p>
* The ledger behind this is day-by-day and has to stay that way — interest is worked out on the
* balance each day carried, and a reversed backdated entry means every day after it is
* recomputed. What a reader wants, though, is the current position without reading thirty rows
* to find it, which is what {@code v_savings_interest_summary} gives: the latest day's figures
* per account, with the cycle total and the lifetime posted total beside them.
*
* <p>
* Optionally narrowed to one product, one office, or one account.
*/
@GET
@Path("/summary")
public String summary(@QueryParam("productId") final Long productId, @QueryParam("officeName") final String officeName,
@QueryParam("accountNo") final String accountNo) {
final List<Object> args = new java.util.ArrayList<>();
final StringBuilder sql = new StringBuilder("SELECT * FROM v_savings_interest_summary WHERE 1 = 1");
if (productId != null) {
sql.append(" AND savings_product_id = ?");
args.add(productId);
}
if (officeName != null && !officeName.isBlank()) {
sql.append(" AND office_name = ?");
args.add(officeName);
}
if (accountNo != null && !accountNo.isBlank()) {
sql.append(" AND account_no = ?");
args.add(accountNo);
}
sql.append(" ORDER BY account_no");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), args.toArray()));
}
/**
* Interest actually paid to members, one row per posting.
*
* <p>
* Read from the savings transactions rather than the daily ledger. The ledger holds one row per
* account carrying the current cycle, and clears the posted amount when the next cycle opens, so
* it cannot answer what was paid last quarter. The transaction is the payment itself and stays.
*
* <p>
* {@code period} narrows to one posting rhythm, matching the product's own
* {@code interest_posting_period_enum}: 1 daily, 4 monthly, 5 quarterly, 6 half-yearly,
* 7 annual. A report screen shows each rhythm on its own, because a monthly product's
* month-end posting and a quarterly product's quarter-end posting are different events and
* mixing them reads as one uneven series.
*/
@GET
@Path("/postings")
public String postings(@QueryParam("period") final Integer period, @QueryParam("productId") final Long productId,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("officeName") final String officeName) {
final List<Object> args = new java.util.ArrayList<>();
final StringBuilder sql = new StringBuilder("""
SELECT t.id AS transaction_id,
t.savings_account_id AS savings_account_id,
sa.account_no AS account_no,
sa.client_id AS client_id,
c.display_name AS client_name,
o.name AS office_name,
sa.product_id AS savings_product_id,
sp.name AS product_name,
sp.interest_posting_period_enum AS posting_period_enum,
sp.nominal_annual_interest_rate AS interest_rate,
sa.currency_code AS currency_code,
-- Formatted here rather than left as a date: this resource serialises with a
-- plain Gson, which renders a java.sql.Date as an object the browser cannot
-- read back, and every posting showed as "Invalid Date".
TO_CHAR(t.transaction_date, 'YYYY-MM-DD') AS posting_date,
t.amount AS interest_amount,
t.running_balance_derived AS balance_after,
(t.running_balance_derived - t.amount) AS balance_before,
t.created_on_utc AS recorded_on
FROM m_savings_account_transaction t
JOIN m_savings_account sa ON sa.id = t.savings_account_id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
WHERE t.transaction_type_enum = 3
AND t.is_reversed = false
AND sp.deposit_type_enum = 100
""");
if (period != null) {
sql.append(" AND sp.interest_posting_period_enum = ? ");
args.add(period);
}
if (productId != null) {
sql.append(" AND sa.product_id = ? ");
args.add(productId);
}
if (fromDate != null && !fromDate.isBlank()) {
sql.append(" AND t.transaction_date >= ? ");
args.add(LocalDate.parse(fromDate));
}
if (toDate != null && !toDate.isBlank()) {
sql.append(" AND t.transaction_date <= ? ");
args.add(LocalDate.parse(toDate));
}
if (officeName != null && !officeName.isBlank()) {
sql.append(" AND o.name = ? ");
args.add(officeName);
}
sql.append(" ORDER BY t.transaction_date DESC, sa.account_no");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), args.toArray()));
}
/** What each posting run pooled and paid, and whether it finished. */
@GET
@Path("/runs")
@@ -0,0 +1,52 @@
/**
* 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.dailyinterest.jobs;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class SavingsDailyInterestConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step savingsDailyInterestStep(SavingsDailyInterestTasklet savingsDailyInterestTasklet) {
return new StepBuilder(JobName.SAVINGS_DAILY_INTEREST.name(), jobRepository)
.tasklet(savingsDailyInterestTasklet, transactionManager).build();
}
@Bean
public Job savingsDailyInterestJob(SavingsDailyInterestTasklet savingsDailyInterestTasklet) {
return new JobBuilder(JobName.SAVINGS_DAILY_INTEREST.name(), jobRepository)
.start(savingsDailyInterestStep(savingsDailyInterestTasklet)).incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,52 @@
/**
* 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.dailyinterest.jobs;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
/**
* Runs the day's savings interest: accrue every account's interest for the business date, then post
* for the products whose cycle ends on it.
*
* <p>
* Until this existed the orchestrator was only reachable through its API, so the ledger accrued for
* as long as somebody kept calling it and posted only when somebody remembered to. Interest a bank
* owes its depositors is not something to run by hand.
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class SavingsDailyInterestTasklet implements Tasklet {
private final SavingsDailyInterestOrchestrator orchestrator;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
orchestrator.runForDate(DateUtils.getBusinessLocalDate());
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,75 @@
/**
* 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.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Getter;
/**
* One account that has gone quiet, as the dormancy reports read it.
*
* <p>
* {@code lastCustomerTransactionDate} is the heart of it: RBI measures the silence from the last
* transaction the <em>customer</em> made, so interest the bank posts and charges it recovers are
* excluded when this is worked out. Where an account has never been transacted on, the activation
* date stands in — the clock has to start somewhere, and that is the day the account began.
*/
@Getter
@Builder
public class DormantAccountData {
private final Long accountId;
private final String accountNo;
private final Long clientId;
private final String clientName;
private final String officeName;
private final String productName;
private final BigDecimal balance;
private final String mobileNo;
/** 100 inactive, 200 dormant, 300 escheat. */
private final Integer subStatus;
private final String subStatusCode;
private final LocalDate lastCustomerTransactionDate;
private final Long daysIdle;
/** The product's own periods, so the report can say when the next mark falls. */
private final Long daysToInactive;
private final Long daysToDormancy;
private final Long daysToEscheat;
/** Escheat only — the day the account was closed and the balance taken out. */
private final LocalDate closedOnDate;
/**
* Escheat only — the day the balance was actually remitted to RBI's DEAF, or null if that has
* not been recorded. A null here on an escheated account means the money is out of the
* customer's account but not yet accounted for as having reached the fund.
*/
private final LocalDate deafRemittedOn;
/** RBI's acknowledgement reference for the remittance. */
private final String deafReference;
/** Set if the customer later claimed the balance and it was recovered from the fund. */
private final LocalDate deafRefundedOn;
}
@@ -88,7 +88,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY");
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
FACILITY_TYPES.put("internetBanking", "INTERNET_BANKING");
@@ -155,8 +154,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig != null && currentConfig.isInterBankTotalCapFacility(),
pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "withdrawalFacility", currentConfig != null && currentConfig.isWithdrawalFacility(),
pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "depositFacilityKyc", currentConfig != null && currentConfig.isDepositFacilityKyc(),
pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
populateFacilityField(fc, "virtualDebitCard", currentConfig != null && currentConfig.isVirtualDebitCard(),
@@ -418,7 +415,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
putIfPresent(fc, "neftFacility", p.getNeftFacility());
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
putIfPresent(fc, "internetBanking", p.getInternetBanking());
@@ -449,7 +445,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current != null && current.isRtgsFacility());
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(),
current != null && current.isInterBankTotalCapFacility());
addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current != null && current.isWithdrawalFacility());
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current != null && current.isDepositFacilityKyc());
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current != null && current.isVirtualDebitCard());
addIfNewlyEnabled(result, "internetBanking", p.getInternetBanking(), current != null && current.isInternetBanking());
@@ -534,7 +529,6 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
putIfNotNull(fc, "internetBanking", p.getInternetBanking());
@@ -91,7 +91,6 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
cfg.put("neftFacility", rs.getBoolean("neft_facility"));
cfg.put("rtgsFacility", rs.getBoolean("rtgs_facility"));
cfg.put("interBankTotalCapFacility", rs.getBoolean("inter_bank_total_cap_facility"));
cfg.put("withdrawalFacility", rs.getBoolean("withdrawal_facility"));
cfg.put("depositFacilityKyc", rs.getBoolean("deposit_facility_kyc"));
cfg.put("virtualDebitCard", rs.getBoolean("virtual_debit_card"));
cfg.put("internetBanking", rs.getBoolean("internet_banking"));
@@ -505,7 +505,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
cfg.setNeftFacility(getBool(fcJson, "neftFacility"));
cfg.setRtgsFacility(getBool(fcJson, "rtgsFacility"));
cfg.setInterBankTotalCapFacility(getBool(fcJson, "interBankTotalCapFacility"));
cfg.setWithdrawalFacility(getBool(fcJson, "withdrawalFacility"));
cfg.setDepositFacilityKyc(getBool(fcJson, "depositFacilityKyc"));
cfg.setVirtualDebitCard(getBool(fcJson, "virtualDebitCard"));
cfg.setInternetBanking(getBool(fcJson, "internetBanking"));
@@ -0,0 +1,40 @@
/**
* 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.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface DeafRemittanceWritePlatformService {
/**
* Records that the escheated balances of the given accounts were remitted to RBI's DEAF.
*
* <p>
* Accounts that are not escheated, or already carry a remittance, are skipped rather than
* rejected — a batch selected in the UI should not fail whole because one row was sent twice.
*/
CommandProcessingResult recordRemittance(JsonCommand command);
/**
* Records that the customer claimed an already-remitted balance back and the bank recovered it
* from the fund.
*/
CommandProcessingResult markRefunded(Long savingsAccountId, JsonCommand command);
}
@@ -0,0 +1,136 @@
/**
* 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.service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.domain.DeafRemittance;
import org.apache.fineract.portfolio.savings.domain.DeafRemittanceRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Records that escheated balances were remitted to RBI's Depositor Education and Awareness Fund.
*
* <p>
* The remittance itself is a transfer made outside this system on RBI's own schedule. All that
* happens here is the recording of it, which is what lets the escheat report tell money that has
* reached the fund from money still sitting with the bank.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DeafRemittanceWritePlatformServiceImpl implements DeafRemittanceWritePlatformService {
private final PlatformSecurityContext context;
private final DeafRemittanceRepository remittanceRepository;
private final SavingsAccountRepository savingsAccountRepository;
@Override
@Transactional
public CommandProcessingResult recordRemittance(final JsonCommand command) {
final Long userId = this.context.authenticatedUser().getId();
final String[] accountIds = command.arrayValueOfParameterNamed("savingsAccountIds") == null ? new String[0]
: command.arrayOfParameterNamed("savingsAccountIds").asList().stream().map(e -> e.getAsString()).toArray(String[]::new);
final LocalDate remittedOn = command.localDateValueOfParameterNamed("remittedOn");
final String deafReference = command.stringValueOfParameterNamed("deafReference");
final String remarks = command.stringValueOfParameterNamed("remarks");
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("deafremittance");
if (accountIds.length == 0) {
validator.reset().parameter("savingsAccountIds").failWithCode("cannot.be.empty");
}
if (remittedOn == null) {
validator.reset().parameter("remittedOn").failWithCode("cannot.be.blank");
} else if (DateUtils.isAfterBusinessDate(remittedOn)) {
// A remittance cannot have been made on a day that has not arrived.
validator.reset().parameter("remittedOn").value(remittedOn).failWithCode("cannot.be.in.the.future");
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors);
}
int recorded = 0;
int skipped = 0;
for (final String rawId : accountIds) {
final Long accountId = Long.valueOf(rawId);
final SavingsAccount account = this.savingsAccountRepository.findById(accountId).orElse(null);
if (account == null) {
skipped++;
continue;
}
// Only an escheated balance is owed to the fund; anything else has not left the customer.
if (!SavingsAccountSubStatusEnum.ESCHEAT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(account.getSubStatus()))) {
log.warn("Savings account {} is not escheated — remittance not recorded", accountId);
skipped++;
continue;
}
// A balance goes to the fund once. Recording it twice would overstate what was sent.
if (this.remittanceRepository.existsBySavingsAccountId(accountId)) {
skipped++;
continue;
}
final LocalDate escheatedOn = account.getClosedOnDate() != null ? account.getClosedOnDate() : remittedOn;
this.remittanceRepository.save(DeafRemittance.record(accountId, account.getClient().getId(),
account.getSummary().getAccountBalance(), escheatedOn, remittedOn, deafReference, remarks, userId));
recorded++;
}
log.info("DEAF remittance recorded for {} account(s), {} skipped, reference {}", recorded, skipped, deafReference);
return new CommandProcessingResultBuilder().withCommandId(command.commandId())
.with(java.util.Map.of("recorded", recorded, "skipped", skipped)).build();
}
@Override
@Transactional
public CommandProcessingResult markRefunded(final Long savingsAccountId, final JsonCommand command) {
this.context.authenticatedUser();
final DeafRemittance remittance = this.remittanceRepository.findBySavingsAccountId(savingsAccountId).orElseThrow(
() -> new IllegalStateException("No DEAF remittance recorded for savings account " + savingsAccountId));
final LocalDate refundedOn = command.localDateValueOfParameterNamed("refundedOn");
remittance.markRefunded(refundedOn != null ? refundedOn : DateUtils.getBusinessLocalDate());
this.remittanceRepository.save(remittance);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(remittance.getId()).build();
}
}
@@ -0,0 +1,37 @@
/**
* 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.service;
import java.util.List;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
/**
* Reads the accounts the dormancy job has marked inactive, dormant or escheated.
*/
public interface DormantAccountReadPlatformService {
/**
* @param subStatus
* 100 inactive, 200 dormant, 300 escheat; null for all three
* @param officeName
* restricts to one branch; null or blank for every branch
* @return the matching accounts, longest idle first
*/
List<DormantAccountData> retrieveDormantAccounts(Integer subStatus, String officeName);
}
@@ -0,0 +1,151 @@
/**
* 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.service;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
/**
* Reads back the accounts the dormancy job has marked.
*
* <p>
* The job writes {@code sub_status} and nothing read it out in bulk, so the branch could not see
* which accounts had gone inactive, dormant or escheated. This is that missing read side.
*
* <p>
* The idle period is recomputed here rather than stored, from the same definition the job uses —
* the last transaction of type 1 (deposit) or 2 (withdrawal), which is to say the last one the
* customer made. Interest postings and charge recoveries carry other type codes and are excluded,
* which is what keeps an account from looking active merely because the bank credited interest to
* it.
*/
@Service
@RequiredArgsConstructor
public class DormantAccountReadPlatformServiceImpl implements DormantAccountReadPlatformService {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final DatabaseSpecificSQLGenerator sqlGenerator;
/** Sub-status codes the dormancy ladder sets. */
private static final int SUB_STATUS_INACTIVE = 100;
private static final int SUB_STATUS_DORMANT = 200;
private static final int SUB_STATUS_ESCHEAT = 300;
@Override
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
this.context.authenticatedUser();
final String lastCustomerTxn = "(select coalesce(max(sat.transaction_date), sa.activatedon_date) "
+ "from m_savings_account_transaction sat where sat.savings_account_id = sa.id "
+ "and sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2))";
final StringBuilder sql = new StringBuilder();
sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, ");
sql.append("sa.account_balance_derived as balance, sa.closedon_date as closedOnDate, ");
sql.append("c.id as clientId, c.display_name as clientName, c.mobile_no as mobileNo, ");
sql.append("o.name as officeName, sp.name as productName, ");
sql.append("sp.days_to_inactive as daysToInactive, sp.days_to_dormancy as daysToDormancy, ");
sql.append("sp.days_to_escheat as daysToEscheat, ");
sql.append("dr.remitted_on as deafRemittedOn, dr.deaf_reference as deafReference, ");
sql.append("dr.refunded_on as deafRefundedOn, ");
sql.append(lastCustomerTxn).append(" as lastCustomerTransactionDate, ");
sql.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" as daysIdle ");
sql.append("from m_savings_account sa ");
sql.append("inner join m_client c on c.id = sa.client_id ");
sql.append("inner join m_office o on o.id = c.office_id ");
sql.append("inner join m_savings_product sp on sp.id = sa.product_id ");
// Left join: an escheated account has a remittance row only once the money has gone to RBI,
// and the absence of one is exactly what the report has to show.
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
// Only the three states the ladder sets. A blocked or a plainly active account is not
// dormant and does not belong on these reports.
sql.append("where sa.sub_status_enum in (?, ?, ?) ");
final List<Object> params = new ArrayList<>();
params.add(DateUtils.getBusinessLocalDate());
params.add(SUB_STATUS_INACTIVE);
params.add(SUB_STATUS_DORMANT);
params.add(SUB_STATUS_ESCHEAT);
if (subStatus != null) {
sql.append("and sa.sub_status_enum = ? ");
params.add(subStatus);
}
if (officeName != null && !officeName.isBlank()) {
sql.append("and o.name = ? ");
params.add(officeName);
}
// Longest idle first: those are the accounts nearest their next mark.
sql.append("order by daysIdle desc, sa.account_balance_derived desc");
return this.jdbcTemplate.query(sql.toString(), new DormantAccountMapper(), params.toArray());
}
private static final class DormantAccountMapper implements RowMapper<DormantAccountData> {
@Override
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final int subStatus = rs.getInt("subStatus");
final BigDecimal balance = rs.getBigDecimal("balance");
final long daysIdle = rs.getLong("daysIdle");
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
.officeName(rs.getString("officeName")).productName(rs.getString("productName"))
// A null balance reads as zero rather than as missing data — an escheated account
// has had its balance taken out and genuinely holds nothing.
.balance(balance == null ? BigDecimal.ZERO : balance).mobileNo(rs.getString("mobileNo"))
.subStatus(subStatus).subStatusCode(subStatusCode(subStatus))
.lastCustomerTransactionDate(rs.getObject("lastCustomerTransactionDate", java.time.LocalDate.class))
.daysIdle(daysIdle).daysToInactive(nullableLong(rs, "daysToInactive"))
.daysToDormancy(nullableLong(rs, "daysToDormancy")).daysToEscheat(nullableLong(rs, "daysToEscheat"))
.closedOnDate(rs.getObject("closedOnDate", java.time.LocalDate.class))
.deafRemittedOn(rs.getObject("deafRemittedOn", java.time.LocalDate.class))
.deafReference(rs.getString("deafReference"))
.deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build();
}
private Long nullableLong(final ResultSet rs, final String column) throws SQLException {
final long value = rs.getLong(column);
return rs.wasNull() ? null : value;
}
private String subStatusCode(final int subStatus) {
return switch (subStatus) {
case SUB_STATUS_INACTIVE -> "savingsAccountSubStatus.inactive";
case SUB_STATUS_DORMANT -> "savingsAccountSubStatus.dormant";
case SUB_STATUS_ESCHEAT -> "savingsAccountSubStatus.escheat";
default -> "savingsAccountSubStatus.none";
};
}
}
}
@@ -83,7 +83,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
FACILITY_TYPES.put("neftFacility", "NEFT_FACILITY");
FACILITY_TYPES.put("rtgsFacility", "RTGS_FACILITY");
FACILITY_TYPES.put("interBankTotalCapFacility", "INTER_BANK_TOTAL_CAP");
FACILITY_TYPES.put("withdrawalFacility", "WITHDRAWAL_FACILITY");
FACILITY_TYPES.put("depositFacilityKyc", "DEPOSIT_FACILITY_KYC");
FACILITY_TYPES.put("virtualDebitCard", "VIRTUAL_DEBIT_CARD");
}
@@ -132,7 +131,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
populateFacilityField(fc, "neftFacility", currentConfig == null ? null : currentConfig.getNeftFacility(), pendingEdit::setNeftFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "rtgsFacility", currentConfig == null ? null : currentConfig.getRtgsFacility(), pendingEdit::setRtgsFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "interBankTotalCapFacility", currentConfig == null ? null : currentConfig.getInterBankTotalCapFacility(), pendingEdit::setInterBankTotalCapFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "withdrawalFacility", currentConfig == null ? null : currentConfig.getWithdrawalFacility(), pendingEdit::setWithdrawalFacility, newlyEnabledFacilityTypes);
populateFacilityField(fc, "depositFacilityKyc", currentConfig == null ? null : currentConfig.getDepositFacilityKyc(), pendingEdit::setDepositFacilityKyc, newlyEnabledFacilityTypes);
populateFacilityField(fc, "virtualDebitCard", currentConfig == null ? null : currentConfig.getVirtualDebitCard(), pendingEdit::setVirtualDebitCard, newlyEnabledFacilityTypes);
}
@@ -370,7 +368,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
putIfPresent(fc, "neftFacility", p.getNeftFacility());
putIfPresent(fc, "rtgsFacility", p.getRtgsFacility());
putIfPresent(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfPresent(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfPresent(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfPresent(fc, "virtualDebitCard", p.getVirtualDebitCard());
return fc;
@@ -397,7 +394,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
addIfNewlyEnabled(result, "neftFacility", p.getNeftFacility(), current == null ? null : current.getNeftFacility());
addIfNewlyEnabled(result, "rtgsFacility", p.getRtgsFacility(), current == null ? null : current.getRtgsFacility());
addIfNewlyEnabled(result, "interBankTotalCapFacility", p.getInterBankTotalCapFacility(), current == null ? null : current.getInterBankTotalCapFacility());
addIfNewlyEnabled(result, "withdrawalFacility", p.getWithdrawalFacility(), current == null ? null : current.getWithdrawalFacility());
addIfNewlyEnabled(result, "depositFacilityKyc", p.getDepositFacilityKyc(), current == null ? null : current.getDepositFacilityKyc());
addIfNewlyEnabled(result, "virtualDebitCard", p.getVirtualDebitCard(), current == null ? null : current.getVirtualDebitCard());
return result;
@@ -483,7 +479,6 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
putIfNotNull(fc, "neftFacility", p.getNeftFacility());
putIfNotNull(fc, "rtgsFacility", p.getRtgsFacility());
putIfNotNull(fc, "interBankTotalCapFacility", p.getInterBankTotalCapFacility());
putIfNotNull(fc, "withdrawalFacility", p.getWithdrawalFacility());
putIfNotNull(fc, "depositFacilityKyc", p.getDepositFacilityKyc());
putIfNotNull(fc, "virtualDebitCard", p.getVirtualDebitCard());
data.setProposedFacilityConfig(fc);
@@ -898,7 +898,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("safc.neft_facility as neftFacility, ");
sqlBuilder.append("safc.rtgs_facility as rtgsFacility, ");
sqlBuilder.append("safc.inter_bank_total_cap_facility as interBankTotalCapFacility, ");
sqlBuilder.append("safc.withdrawal_facility as withdrawalFacility, ");
sqlBuilder.append("safc.deposit_facility_kyc as depositFacilityKyc, ");
sqlBuilder.append("safc.virtual_debit_card as virtualDebitCard, ");
sqlBuilder.append("sa.terminate_true as terminateTrue ");
@@ -1178,7 +1177,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
facilityConfigMap.put("neftFacility", rs.getBoolean("neftFacility"));
facilityConfigMap.put("rtgsFacility", rs.getBoolean("rtgsFacility"));
facilityConfigMap.put("interBankTotalCapFacility", rs.getBoolean("interBankTotalCapFacility"));
facilityConfigMap.put("withdrawalFacility", rs.getBoolean("withdrawalFacility"));
facilityConfigMap.put("depositFacilityKyc", rs.getBoolean("depositFacilityKyc"));
facilityConfigMap.put("virtualDebitCard", rs.getBoolean("virtualDebitCard"));
accountData.setAccountFacilityConfig(facilityConfigMap);
@@ -112,6 +112,8 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact
import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge;
@@ -166,6 +168,8 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
private final AppUserRepositoryWrapper appuserRepository;
private final StandingInstructionRepository standingInstructionRepository;
private final BusinessEventNotifierService businessEventNotifierService;
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final SavingsDailyInterestService savingsDailyInterestService;
private final GSIMRepositoy gsimRepository;
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
private final ErrorHandler errorHandler;
@@ -232,6 +236,17 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
businessEventNotifierService.notifyPostBusinessEvent(new SavingsActivateBusinessEvent(account));
// Open the daily-interest ledger for the account here and now. The nightly run would find it
// tomorrow, but until then the account is absent from the ledger, which reads to a branch as
// though it is earning nothing. The product is enrolled first: an account whose product has
// no posting timetable would accrue with nothing to pay it out.
//
// Neither call can fail the activation — both run in their own transaction and swallow their
// own errors — because an account that is open must stay open even if its ledger row could
// not be written.
savingsDailyInterestOrchestrator.enrolProductOf(savingsId);
savingsDailyInterestService.accrueOnActivation(savingsId, DateUtils.getBusinessLocalDate());
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
@@ -140,7 +140,6 @@ public class SavingsProductReadPlatformServiceImpl implements SavingsProductRead
facilityConfigMap.put("neftFacility", facilityConfig.getNeftFacility());
facilityConfigMap.put("rtgsFacility", facilityConfig.getRtgsFacility());
facilityConfigMap.put("interBankTotalCapFacility", facilityConfig.getInterBankTotalCapFacility());
facilityConfigMap.put("withdrawalFacility", facilityConfig.getWithdrawalFacility());
facilityConfigMap.put("depositFacilityKyc", facilityConfig.getDepositFacilityKyc());
facilityConfigMap.put("virtualDebitCard", facilityConfig.getVirtualDebitCard());
product.setAccountFacilityConfig(facilityConfigMap);
@@ -52,6 +52,7 @@ import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.data.SavingsProductDataValidator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService;
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKyc;
import org.apache.fineract.portfolio.savings.domain.SavingsProductDepositLimitKycRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig;
@@ -103,6 +104,7 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
private final ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository;
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository;
private final SavingsProductTermsCascadeService termsCascadeService;
/*
* Guaranteed to throw an exception no matter what the data integrity issue is.
@@ -295,6 +297,19 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
this.savingProductRepository.saveAndFlush(product);
}
// Interest terms are copied onto each account at creation and the engine reads that
// copy, so a rate or posting-period change here would otherwise leave every existing
// account on the terms it was opened with. Interest follows the product at this bank,
// so the edit is pushed down. Same transaction as the save: the product and its
// accounts move together or not at all.
//
// approveEdit() replays a maker-checker edit through this same method, so the checker
// path is covered by this one call.
final int accountsRealigned = this.termsCascadeService.cascadeInterestTerms(product.getId(), changes);
if (accountsRealigned > 0) {
changes.put("accountsRealignedToProductTerms", accountsRealigned);
}
return new CommandProcessingResultBuilder() //
.withEntityId(product.getId()) //
.with(changes).build();
@@ -84,6 +84,8 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact
import org.apache.fineract.portfolio.savings.domain.DepositProductAssembler;
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccountRepository;
import org.apache.fineract.portfolio.savings.domain.FixedDepositProductRepository;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccountRepository;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProductRepository;
@@ -473,6 +475,7 @@ public class SavingsConfiguration {
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator, SavingsDailyInterestService savingsDailyInterestService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter, VoucherService voucherService) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, voucherService, fromApiJsonDeserializer,
@@ -482,7 +485,8 @@ public class SavingsConfiguration {
savingsAccountDomainService, noteRepository, accountTransfersReadPlatformService, accountAssociationsReadPlatformService,
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
standingInstructionRepository, businessEventNotifierService, savingsDailyInterestOrchestrator,
savingsDailyInterestService, gsimRepository, savingsAccountInterestPostingService,
errorHandler, denominationBreakdownWriter);
}
@@ -576,12 +580,13 @@ public class SavingsConfiguration {
org.apache.fineract.portfolio.savings.domain.ProductPendingEditChargeRepository pendingEditChargeRepository,
org.apache.fineract.portfolio.savings.domain.ProductPendingEditFacilityLimitRepository pendingEditFacilityLimitRepository,
org.apache.fineract.portfolio.savings.domain.ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository) {
org.apache.fineract.portfolio.savings.domain.ProductPendingEditSubmittedFieldRepository pendingEditSubmittedFieldRepository,
org.apache.fineract.portfolio.savings.dailyinterest.SavingsProductTermsCascadeService termsCascadeService) {
return new SavingsProductWritePlatformServiceJpaRepositoryImpl(context, savingProductRepository, fromApiJsonDataValidator,
savingsProductAssembler, accountMappingWritePlatformService, fineractEntityAccessUtil, facilityConfigRepository,
facilityLimitRepository, depositLimitKycRepository, productPendingEditRepository, fromApiJsonHelper,
commandSourceRepository, pendingEditChargeRepository, pendingEditFacilityLimitRepository, pendingEditGlMappingRepository,
pendingEditSubmittedFieldRepository);
pendingEditSubmittedFieldRepository, termsCascadeService);
}
@Bean
@@ -50,7 +50,9 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDSlipLine;
import org.apache.fineract.portfolio.sponsordd.domain.SponsorDDRecoBatch;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDAmendmentRequestWritePlatformService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDIssueRequestWritePlatformService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReadPlatformService;
import org.apache.fineract.portfolio.sponsordd.service.SponsorDDReconciliationService;
@@ -70,6 +72,7 @@ public class SponsorDDApiResource {
private final SponsorDDReconciliationService reconciliationService;
private final SponsorDDIssueRequestWritePlatformService issueRequestWritePlatformService;
private final SponsorDDReprintRequestWritePlatformService reprintRequestWritePlatformService;
private final SponsorDDAmendmentRequestWritePlatformService amendmentRequestWritePlatformService;
private final PlatformSecurityContext context;
private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final Gson gson = new GsonBuilder().serializeNulls().create();
@@ -233,6 +236,18 @@ public class SponsorDDApiResource {
return safely(() -> this.writePlatformService.sendAdvice(request.getSponsorBankId()));
}
@GET
@Path("leaflets/next-available")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve next available DD leaflet", description = "Returns the next sequential available leaflet for a branch and sponsor bank.")
public SponsorDDLeafletData retrieveNextAvailableLeaflet(
@QueryParam("branchId") final Long branchId,
@QueryParam("sponsorBankId") final Long sponsorBankId,
@QueryParam("inventoryId") final Long inventoryId) {
return this.readPlatformService.retrieveNextAvailableLeaflet(branchId, sponsorBankId, inventoryId);
}
// ── Maker-checker DD issuance (clerk submits, manager/head-cashier approves) ──
@POST
@@ -260,7 +275,8 @@ public class SponsorDDApiResource {
request.getCurrentAccountId(),
request.getChequeNumber(),
request.getChequeBankName(),
request.getCashierDepositRequestId()
request.getCashierDepositRequestId(),
request.getDenominationData()
));
}
@@ -410,6 +426,48 @@ public class SponsorDDApiResource {
return safely(() -> this.reprintRequestWritePlatformService.rejectReprintRequest(reprintRequestId, request.getRejectionReason()));
}
// ── Amending an issued DD (beneficiary, payable-at branch and city) ────
@POST
@Path("leaflets/{leafletId}/amendment-request")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Request an amendment to an issued DD", description = "Clerk (maker) step: requests manager approval to correct the beneficiary name, or the branch and city the draft is payable at. The draft is unchanged until approved.")
public CommandProcessingResult requestAmendment(@PathParam("leafletId") final Long leafletId,
final AmendmentRequestRequest request) {
return safely(() -> this.amendmentRequestWritePlatformService.requestAmendment(leafletId, request.getBeneficiaryName(),
request.getPayableAtBranch(), request.getPayableAtCity(), request.getReason()));
}
@GET
@Path("amendment-requests")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DD amendment requests", description = "Returns DD amendment requests, optionally filtered by status (PENDING/APPROVED/REJECTED), for the manager approval queue. Each row carries both the old and the new details.")
public Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(@QueryParam("status") final String status) {
return this.readPlatformService.retrieveAllAmendmentRequests(status);
}
@POST
@Path("amendment-requests/{amendmentRequestId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a DD amendment request", description = "Manager/head-cashier (checker) step: applies the corrected details to the draft.")
public CommandProcessingResult approveAmendmentRequest(@PathParam("amendmentRequestId") final Long amendmentRequestId) {
return safely(() -> this.amendmentRequestWritePlatformService.approveAmendmentRequest(amendmentRequestId));
}
@POST
@Path("amendment-requests/{amendmentRequestId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a DD amendment request", description = "Manager/head-cashier (checker) step: refuses the correction, leaving the draft as issued.")
public CommandProcessingResult rejectAmendmentRequest(@PathParam("amendmentRequestId") final Long amendmentRequestId,
final RejectIssueRequestRequest request) {
return safely(() -> this.amendmentRequestWritePlatformService.rejectAmendmentRequest(amendmentRequestId,
request.getRejectionReason()));
}
// ── Sponsor DD GL account configuration ───────────────────────────────
@GET
@@ -568,6 +626,7 @@ public class SponsorDDApiResource {
// CASH only — the cashier's already-APPROVED Transaction Entry (AC.TP=GL) deposit request
// that received this cash, credited to the DD Cash Suspense GL account.
private Long cashierDepositRequestId;
private String denominationData;
}
@lombok.Data
@@ -602,6 +661,20 @@ public class SponsorDDApiResource {
private String reason;
}
/**
* The three details of an issued draft that can be corrected.
*
* A field left blank keeps what the draft already carries, so correcting only the city does not
* mean retyping the beneficiary and risking a second mistake.
*/
@lombok.Data
public static class AmendmentRequestRequest {
private String beneficiaryName;
private String payableAtBranch;
private String payableAtCity;
private String reason;
}
@lombok.Data
public static class GlMappingRequest {
private Long sponsorBankId;
@@ -0,0 +1,62 @@
/**
* 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.sponsordd.data;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* A pending or settled correction to an issued Demand Draft.
*
* Carries both the new details and the ones they replace, so the approval queue can show a checker
* what is actually changing rather than only what it would become — and so the record still reads
* correctly after the leaflet itself has been updated.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SponsorDDAmendmentRequestData implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Long leafletId;
private String ddNumber;
private String beneficiaryName;
private String payableAtBranch;
private String payableAtCity;
private String oldBeneficiaryName;
private String oldPayableAtBranch;
private String oldPayableAtCity;
private String reason;
private String status;
private Long makerId;
private String makerUsername;
private LocalDateTime createdDate;
private Long checkerId;
private String checkerUsername;
private LocalDateTime checkerDate;
private String rejectionReason;
}
@@ -35,16 +35,22 @@ public class SponsorDDGlMappingData implements Serializable {
private String sponsorBankName;
private Long ddPayableGlAccountId;
private String ddPayableGlAccountName;
private String ddPayableGlCode;
private Long chequeClearingGlAccountId;
private String chequeClearingGlAccountName;
private String chequeClearingGlCode;
private Long cashGlAccountId;
private String cashGlAccountName;
private String cashGlCode;
private Long chargesIncomeGlAccountId;
private String chargesIncomeGlAccountName;
private String chargesIncomeGlCode;
private Long cashSuspenseGlAccountId;
private String cashSuspenseGlAccountName;
private String cashSuspenseGlCode;
private BigDecimal gstPercentage;
private Long gstPayableGlAccountId;
private String gstPayableGlAccountName;
private String gstPayableGlCode;
private BigDecimal ddLimitAmount;
}
@@ -57,4 +57,6 @@ public class SponsorDDLeafletData implements Serializable {
private Long approvedUnconsumedReprintRequestId;
private Long approverUserId;
private String approverUsername;
/** Set while a correction to this draft is awaiting a manager. */
private Long pendingAmendmentRequestId;
}
@@ -0,0 +1,45 @@
/**
* 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.sponsordd.service;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
/**
* Corrections to an already-issued Demand Draft, under maker-checker.
*
* Only the beneficiary's name and the branch and city the draft is payable at can be amended. Those
* are details of who may present the instrument and where; getting one wrong makes the draft
* unpresentable, and the purchaser is already holding it by the time anyone notices.
*
* Face value, purchaser and funding are deliberately not amendable. The money for those has already
* moved, and editing them would leave the ledger disagreeing with the instrument — a draft wrong in
* that way is cancelled and re-issued instead.
*/
public interface SponsorDDAmendmentRequestWritePlatformService {
/** Maker step: records what the clerk wants changed, leaving the leaflet untouched. */
CommandProcessingResult requestAmendment(Long leafletId, String beneficiaryName, String payableAtBranch, String payableAtCity,
String reason);
/** Checker step: applies the change to the leaflet. */
CommandProcessingResult approveAmendmentRequest(Long amendmentRequestId);
/** Checker step: refuses the change, leaving the draft as issued. */
CommandProcessingResult rejectAmendmentRequest(Long amendmentRequestId, String rejectionReason);
}
@@ -0,0 +1,217 @@
/**
* 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.sponsordd.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SponsorDDAmendmentRequestWritePlatformServiceImpl implements SponsorDDAmendmentRequestWritePlatformService {
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_APPROVED = "APPROVED";
private static final String STATUS_REJECTED = "REJECTED";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
@Override
@Transactional
public CommandProcessingResult requestAmendment(final Long leafletId, final String beneficiaryName,
final String payableAtBranch, final String payableAtCity, final String reason) {
final AppUser currentUser = this.context.authenticatedUser();
final Map<String, Object> leaflet = findLeafletOrThrow(leafletId);
// Only an issued draft is amendable. A cancelled or stopped one is out of circulation and a
// cleared one has already been paid, so correcting where it is payable would mean nothing.
if (!"ISSUED".equals(leaflet.get("status"))) {
throw new IllegalStateException(
"Only an issued Demand Draft can be amended. Current status: " + leaflet.get("status"));
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("A reason is required to amend an issued Demand Draft.");
}
final String newBeneficiary = trimToNull(beneficiaryName);
final String newBranch = trimToNull(payableAtBranch);
final String newCity = trimToNull(payableAtCity);
if (newBeneficiary == null && newBranch == null && newCity == null) {
throw new IllegalArgumentException("Enter the beneficiary name, the payable-at branch or the city to amend.");
}
final String oldBeneficiary = (String) leaflet.get("beneficiary_name");
final String oldBranch = (String) leaflet.get("payable_at_branch");
final String oldCity = (String) leaflet.get("payable_at_city");
// An amendment that changes nothing still costs a checker's attention, so it is refused
// rather than queued.
if (Objects.equals(newBeneficiary, oldBeneficiary) && Objects.equals(newBranch, oldBranch)
&& Objects.equals(newCity, oldCity)) {
throw new IllegalArgumentException("The details submitted are the same as the ones on the draft.");
}
if (!this.jdbcTemplate
.queryForList("SELECT id FROM m_sponsor_dd_amendment_request WHERE leaflet_id = ? AND status = 'PENDING'", leafletId)
.isEmpty()) {
throw new IllegalStateException(
"An amendment is already pending for this Demand Draft. Wait for it to be approved or rejected.");
}
// Blank fields keep what the draft already carries, so a clerk correcting only the city does
// not have to retype the beneficiary and risk a second mistake.
final String finalBeneficiary = newBeneficiary != null ? newBeneficiary : oldBeneficiary;
final String finalBranch = newBranch != null ? newBranch : oldBranch;
final String finalCity = newCity != null ? newCity : oldCity;
final KeyHolder kh = new GeneratedKeyHolder();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(con -> {
final java.sql.PreparedStatement ps = con.prepareStatement("INSERT INTO m_sponsor_dd_amendment_request "
+ "(leaflet_id, beneficiary_name, payable_at_branch, payable_at_city, "
+ " old_beneficiary_name, old_payable_at_branch, old_payable_at_city, "
+ " reason, status, maker_id, created_date) VALUES (?,?,?,?,?,?,?,?,?,?,?)", new String[] { "id" });
ps.setLong(1, leafletId);
ps.setString(2, finalBeneficiary);
ps.setString(3, finalBranch);
ps.setString(4, finalCity);
ps.setString(5, oldBeneficiary);
ps.setString(6, oldBranch);
ps.setString(7, oldCity);
ps.setString(8, reason.trim());
ps.setString(9, STATUS_PENDING);
ps.setLong(10, currentUser.getId());
ps.setObject(11, now);
return ps;
}, kh);
final long newId = Objects.requireNonNull(kh.getKey()).longValue();
return new CommandProcessingResultBuilder().withEntityId(newId).build();
}
@Override
@Transactional
public CommandProcessingResult approveAmendmentRequest(final Long amendmentRequestId) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findAmendmentRequestOrThrow(amendmentRequestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Amendment request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot approve your own amendment request.");
}
final Long leafletId = ((Number) request.get("leaflet_id")).longValue();
final Map<String, Object> leaflet = findLeafletOrThrow(leafletId);
// Re-checked at approval: the draft may have been cancelled or stopped while the request sat
// in the queue, and amending one that has left circulation would be a silent no-op.
if (!"ISSUED".equals(leaflet.get("status"))) {
throw new IllegalStateException("This Demand Draft is no longer issued. Current status: " + leaflet.get("status"));
}
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(
"UPDATE m_sponsor_dd_leaflet SET beneficiary_name = ?, payable_at_branch = ?, payable_at_city = ? WHERE id = ?",
request.get("beneficiary_name"), request.get("payable_at_branch"), request.get("payable_at_city"), leafletId);
this.jdbcTemplate.update(
"UPDATE m_sponsor_dd_amendment_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?",
STATUS_APPROVED, currentUser.getId(), now, amendmentRequestId);
return new CommandProcessingResultBuilder().withEntityId(amendmentRequestId).withSubEntityId(leafletId).build();
}
@Override
@Transactional
public CommandProcessingResult rejectAmendmentRequest(final Long amendmentRequestId, final String rejectionReason) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findAmendmentRequestOrThrow(amendmentRequestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Amendment request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot reject your own amendment request.");
}
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update("UPDATE m_sponsor_dd_amendment_request SET status = ?, checker_id = ?, checker_date = ?, "
+ "rejection_reason = ? WHERE id = ?", STATUS_REJECTED, currentUser.getId(), now, rejectionReason,
amendmentRequestId);
return new CommandProcessingResultBuilder().withEntityId(amendmentRequestId).build();
}
private static String trimToNull(final String value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id, status, beneficiary_name, payable_at_branch, payable_at_city FROM m_sponsor_dd_leaflet WHERE id = ?",
leafletId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Demand Draft leaflet not found.");
}
return rows.get(0);
}
private Map<String, Object> findAmendmentRequestOrThrow(final Long amendmentRequestId) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM m_sponsor_dd_amendment_request WHERE id = ?", amendmentRequestId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Sponsor DD amendment request not found.");
}
return rows.get(0);
}
private boolean isCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return true;
}
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
}
private void validateCheckerRole(final AppUser user) {
if (!isCheckerRole(user)) {
throw new IllegalStateException("Only a Bank Manager (or Head Cashier) can approve or reject amendment requests.");
}
}
}
@@ -42,7 +42,7 @@ public interface SponsorDDIssueRequestWritePlatformService {
String walkinPurchaserAddress, String walkinPurchaserAadhaar, String beneficiaryName, String payableAtBranch,
String payableAtCity, BigDecimal faceValue, BigDecimal chargesAmount, Long issuingBranchId, LocalDate issuedDate,
String paymentMethod, Long savingsAccountId, Long currentAccountId, String chequeNumber, String chequeBankName,
Long cashierDepositRequestId);
Long cashierDepositRequestId, String denominationData);
/**
* Manager/head-cashier (checker) step: performs the real debit/credit GL posting
@@ -101,7 +101,7 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final String payableAtBranch, final String payableAtCity, final BigDecimal faceValue, final BigDecimal chargesAmount,
final Long issuingBranchId, final LocalDate issuedDate, final String paymentMethod, final Long savingsAccountId,
final Long currentAccountId, final String chequeNumber, final String chequeBankName,
final Long cashierDepositRequestId) {
final Long cashierDepositRequestId, final String denominationData) {
final AppUser currentUser = this.context.authenticatedUser();
@@ -138,13 +138,45 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
String walkinPurchaserAadhaarMasked = null;
Long cashReceiptJournalEntryId = null;
// Set when this service posts the counter receipt itself, so the receipt slip is raised once the
// request row exists to key it by. A receipt against a separate cashier deposit voucher is not
// included: that register raised its own slip when it took the notes.
boolean cashReceiptVoucherPending = false;
if (METHOD_CASH.equals(paymentMethod)) {
walkinPurchaserAadhaarMasked = maskAadhaar(walkinPurchaserAadhaar);
final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
final BigDecimal expectedTotal = faceValue.add(effectiveCharges).add(gstAmount);
final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
if (cashierDepositRequestId != null) {
final Map<String, Object> depositRequest = validateCashierDepositRequestOrThrow(cashierDepositRequestId, expectedTotal,
cashSuspenseGlAccountId);
cashReceiptJournalEntryId = findJournalEntryIdForCashierDeposit(depositRequest, cashSuspenseGlAccountId);
} else {
// Taking cash straight over the counter, with no deposit request to have counted it.
// The breakdown is the only record of which notes were handed over, and the closing
// report is built entirely from breakdowns, so a draft accepted without one puts
// money in the drawer that no cash report can see. It was optional here, and every
// draft raised this way went in blank.
// An empty object counts as absent: a caller that sends "{}" has told us nothing
// about the notes, and the amount check below would pass it as a zero total.
if (denominationData == null || denominationData.isBlank() || denominationData.replaceAll("\\s", "").equals("{}")) {
throw new IllegalArgumentException(
"Enter the note and coin breakdown for the cash taken, or fund this draft from an approved cashier deposit request.");
}
validateDenominationTotal(denominationData, expectedTotal);
// The purchaser has handed over the notes to get this far, so the receipt is posted
// now rather than at approval. Cash in the drawer that the ledger does not know about
// is a cash difference at the end of the day, and if the request is later rejected
// there is nothing for rejectIssueRequest() to refund against.
//
// It lands in the cash-suspense account, not DD Payable: the draft has not been
// approved, so the bank holds the money without yet owing it on an instrument.
// Approval moves it on; rejection refunds it.
final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue();
final String receiptComments = "Cash received for DD issuance request - " + beneficiaryName;
cashReceiptJournalEntryId = postGlOffsetEntry(cashGlAccountId, cashSuspenseGlAccountId, expectedTotal, null, null,
issuingBranchId, issuedDate, receiptComments, null);
cashReceiptVoucherPending = true;
}
} else {
validateClientExists(clientId);
if (savingsAccountId != null) validateAccountBelongsToClient("m_savings_account", savingsAccountId, clientId, "Savings account");
@@ -168,8 +200,8 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
+ "beneficiary_name, payable_at_branch, payable_at_city, face_value, charges_amount, gst_amount, "
+ "gst_percentage_applied, issuing_branch_id, issued_date, payment_method, savings_account_id, "
+ "current_account_id, cheque_number, cheque_bank_name, status, maker_id, created_date, "
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "cashier_deposit_request_id, cash_receipt_journal_entry_id, denomination_data) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, leafletId);
if (clientId != null) ps.setLong(2, clientId); else ps.setNull(2, java.sql.Types.BIGINT);
@@ -195,10 +227,19 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
ps.setObject(22, now);
if (finalCashierDepositRequestId != null) ps.setLong(23, finalCashierDepositRequestId); else ps.setNull(23, java.sql.Types.BIGINT);
if (finalCashReceiptJournalEntryId != null) ps.setLong(24, finalCashReceiptJournalEntryId); else ps.setNull(24, java.sql.Types.BIGINT);
ps.setString(25, denominationData);
return ps;
}, kh);
final long newId = Objects.requireNonNull(kh.getKey()).longValue();
if (METHOD_CASH.equals(paymentMethod) && cashierDepositRequestId == null && denominationData != null && !denominationData.isBlank()) {
savePendingDenomination(denominationData, newId, currentUser.getId(), currentUser.getUsername());
}
if (cashReceiptVoucherPending) {
raiseCashReceiptVoucher(newId, beneficiaryName, faceValue.add(effectiveCharges).add(gstAmount),
((Number) glMapping.get("cash_gl_account_id")).longValue(),
((Number) glMapping.get("cash_suspense_gl_account_id")).longValue(), currentUser);
}
return new CommandProcessingResultBuilder().withEntityId(newId).build();
}
@@ -500,9 +541,20 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
// and what is still unclaimed as two separate balances rather than only the net.
drGlAccountId = resolveDividendFundingGlAccount(faceValue, issuingBranchId, issuedDate, ddNumber);
} else {
// Cash was already recorded (Dr Cash at Tellers / Cr this suspense account) when the
// request was submitted, so approval clears the suspense rather than debiting cash again.
// Cash payment. The money was taken at submission and is sitting in the cash-suspense
// account, whichever way it got there — a separate cashier deposit voucher, or the
// receipt this service posts when the purchaser pays at the counter. Approval moves it
// out of suspense onto the instrument.
//
// Debiting the cash account here instead would count the same notes twice: once when
// they were received, once again now.
if (request.get("cash_receipt_journal_entry_id") != null || request.get("cashier_deposit_request_id") != null) {
drGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
} else {
// A request raised before the receipt was posted at submission: the cash never went
// through suspense, so it comes straight off the cash account.
drGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue();
}
}
final String comments = "Sponsor DD issuance - DD No " + ddNumber;
@@ -523,6 +575,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
STATUS_APPROVED, currentUser.getId(), now, resultingSavingsTransactionId, resultingCurrentTransactionId,
resultingJournalEntryId, issueRequestId);
if (resultingJournalEntryId != null) {
this.jdbcTemplate.update(
"UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ? AND account_no = 'DD-ISSUE'",
resultingJournalEntryId, issueRequestId);
}
if (METHOD_DIVIDEND.equals(paymentMethod)) {
settleDividendClaim(issueRequestId, ddNumber, faceValue, currentUser.getId(), currentUser.getDisplayName());
}
@@ -609,8 +667,10 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
final Long cashGlAccountId = ((Number) glMapping.get("cash_gl_account_id")).longValue();
final Long cashSuspenseGlAccountId = ((Number) glMapping.get("cash_suspense_gl_account_id")).longValue();
final String refundComments = "DD issuance request #" + issueRequestId + " rejected - cash refunded to walk-in purchaser";
final BigDecimal refundTotal = faceValue.add(effectiveCharges).add(effectiveGst);
cashRefundJournalEntryId = postGlOffsetEntry(cashSuspenseGlAccountId, cashGlAccountId,
faceValue.add(effectiveCharges).add(effectiveGst), null, null, issuingBranchId, issuedDate, refundComments, null);
refundTotal, null, null, issuingBranchId, issuedDate, refundComments, null);
raiseCashRefundVoucher(issueRequestId, refundTotal, cashSuspenseGlAccountId, cashGlAccountId, currentUser);
}
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
@@ -782,21 +842,15 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
return requests;
}
// ── GL mapping configuration ───────────────────────────────────────────
@Override
@Transactional
public CommandProcessingResult upsertGlMapping(final Long paymentTypeId, final Long ddPayableGlAccountId,
public CommandProcessingResult upsertGlMapping(final Long sponsorBankId, final Long ddPayableGlAccountId,
final Long chequeClearingGlAccountId, final Long cashGlAccountId, final Long chargesIncomeGlAccountId,
final Long cashSuspenseGlAccountId, final BigDecimal gstPercentage, final Long gstPayableGlAccountId,
final BigDecimal ddLimitAmount) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
if (gstPercentage != null && (gstPercentage.compareTo(BigDecimal.ZERO) < 0 || gstPercentage.compareTo(BigDecimal.valueOf(100)) > 0)) {
throw new IllegalArgumentException("GST percentage must be between 0 and 100.");
}
if (ddLimitAmount != null && ddLimitAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("DD Limit Amount cannot be negative.");
if (ddPayableGlAccountId == null) {
throw new IllegalArgumentException("Sponsor DD Payable GL account is required.");
}
final BigDecimal effectiveGstPercentage = gstPercentage != null ? gstPercentage : BigDecimal.valueOf(18);
@@ -839,6 +893,82 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
// ── Validation helpers ─────────────────────────────────────────────────
private final com.google.gson.Gson gson = new com.google.gson.Gson();
private void validateDenominationTotal(final String denominationData, final BigDecimal expectedTotal) {
if (denominationData == null || denominationData.isBlank()) return;
final JsonObject denomMap;
try {
denomMap = this.gson.fromJson(denominationData, JsonObject.class);
} catch (Exception e) {
throw new IllegalArgumentException("Denomination data is not valid JSON.");
}
if (denomMap == null || denomMap.size() == 0) return;
long total = 0;
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
final int count;
try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; }
if (count <= 0) continue;
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) continue;
final int value;
try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; }
total += (long) value * count;
}
// Cash is collected in whole rupees, so the breakdown is checked against the payable amount
// rounded up rather than against the paise. A GST-bearing draft comes to something like
// ₹111.80, and no combination of notes and coins reaches that -- demanding an exact match
// made the breakdown impossible to satisfy for the very drafts that most need one. Either
// figure is accepted, so a draft that happens to land on a whole rupee still balances.
final BigDecimal roundedUp = expectedTotal.setScale(0, java.math.RoundingMode.CEILING);
final BigDecimal counted = BigDecimal.valueOf(total);
if (counted.compareTo(expectedTotal) != 0 && counted.compareTo(roundedUp) != 0) {
throw new IllegalArgumentException("Total cash denomination (₹" + total + ") does not match total payable amount (₹"
+ expectedTotal + ", collected as ₹" + roundedUp + ").");
}
}
private void savePendingDenomination(final String denominationData, final Long issueRequestId,
final Long cashierId, final String cashierUsername) {
if (denominationData == null || denominationData.isBlank()) return;
final JsonObject denomMap;
try {
denomMap = this.gson.fromJson(denominationData, JsonObject.class);
} catch (Exception e) {
return;
}
if (denomMap == null || denomMap.size() == 0) return;
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
final int count;
try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; }
if (count <= 0) continue;
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) continue;
final String kind = parts[0];
final int value;
try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; }
final List<Long> ids = this.jdbcTemplate.queryForList(
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?"
+ " AND (deleted IS NULL OR deleted = false) LIMIT 1",
Long.class, value, kind);
if (ids.isEmpty()) continue;
final Long denomId = ids.get(0);
this.jdbcTemplate.update(
"INSERT INTO m_savings_txn_denomination "
+ "(txn_id, savings_account_id, account_no, client_id, denomination_id, "
+ " denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username, payment_type) "
+ "VALUES (?, NULL, 'DD-ISSUE', NULL, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?, 'CASH')",
issueRequestId, denomId, value, count, BigDecimal.valueOf((long) value * count),
cashierId, cashierUsername
);
}
}
private Map<String, Object> findLeafletOrThrow(final Long leafletId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id, status, inventory_id, dd_number FROM m_sponsor_dd_leaflet WHERE id = ?", leafletId);
@@ -868,25 +998,25 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
+ "cash_suspense_gl_account_id, gst_percentage, gst_payable_gl_account_id, dd_limit_amount FROM m_sponsor_dd_gl_mapping LIMIT 1");
if (rows.isEmpty()) {
throw new IllegalStateException(
"No Sponsor DD GL mapping is configured for this sponsor bank. Ask an administrator to configure the Sponsor DD Payable GL account first.");
"DD GL mapping is not configured. Ask an administrator to configure the Sponsor DD GL accounts in Admin > Sponsor DD GST / GL Settings first.");
}
final Map<String, Object> mapping = rows.get(0);
if (mapping.get("dd_payable_gl_account_id") == null) {
throw new IllegalStateException(
"No Sponsor DD Payable GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.");
}
if (METHOD_CHEQUE.equals(paymentMethod) && mapping.get("cheque_clearing_gl_account_id") == null) {
throw new IllegalStateException(
"No cheque-clearing GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cheque payments.");
"No cheque-clearing GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before accepting cheque payments.");
}
if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_gl_account_id") == null) {
throw new IllegalStateException(
"No cash-in-hand GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments.");
}
if (METHOD_CASH.equals(paymentMethod) && mapping.get("cash_suspense_gl_account_id") == null) {
throw new IllegalStateException(
"No cash-received-pending-approval GL account is configured for this sponsor bank. Ask an administrator to configure it before accepting cash payments.");
"No cash-in-hand GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before accepting cash payments.");
}
final boolean hasCharges = chargesAmount != null && chargesAmount.compareTo(BigDecimal.ZERO) > 0;
if (hasCharges && mapping.get("charges_income_gl_account_id") == null) {
throw new IllegalStateException(
"No charges/commission income GL account is configured for this sponsor bank. Ask an administrator to configure it before collecting DD issuance charges.");
"No charges/commission income GL account is configured. Ask an administrator to configure it in Admin > Sponsor DD GST / GL Settings before collecting DD issuance charges.");
}
final BigDecimal gstPercentage = (BigDecimal) mapping.get("gst_percentage");
if (hasCharges && gstPercentage != null && gstPercentage.compareTo(BigDecimal.ZERO) > 0
@@ -1217,6 +1347,72 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
currentAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
/**
* Records the cash taken over the counter for a Demand Draft request in the voucher register.
*
* A receipt (CR), because the prefix names what happened to cash: the purchaser has handed notes
* over and the drawer is heavier. The approval slip raised later is a journal voucher — by then
* the money is only moving between GLs — and a rejection is a payment, because the notes go back.
*
* The credit is cash-suspense rather than DD Payable. The draft has not been approved, so the bank
* holds the money without yet owing it on an instrument.
*
* A failure here does not fail the submission: the notes are counted and the ledger already has
* the receipt, and refusing the request over a slip would leave the purchaser's money unrecorded.
*/
private void raiseCashReceiptVoucher(final Long issueRequestId, final String beneficiaryName, final BigDecimal total,
final Long cashGlAccountId, final Long cashSuspenseGlAccountId, final AppUser currentUser) {
try {
final List<VoucherLine> lines = new ArrayList<>();
final Map<String, Object> cash = this.voucherService.glAccount(cashGlAccountId);
lines.add(VoucherLine.debitGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total)
.withDescription("Cash received for DD to " + beneficiaryName));
final Map<String, Object> suspense = this.voucherService.glAccount(cashSuspenseGlAccountId);
lines.add(VoucherLine
.creditGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total)
.withDescription("Held pending DD approval"));
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DEPOSIT, REF_SPONSOR_DD_ISSUE, issueRequestId,
currentUser, VoucherService.PREFIX_CREDIT, lines);
} catch (final RuntimeException e) {
log.error("Could not raise a cash receipt voucher for Sponsor DD issue request {}", issueRequestId, e);
}
}
/**
* Records cash handed back to the purchaser when a Demand Draft request is rejected.
*
* A payment (DR): the notes leave the drawer. The receipt raised at submission stands — the money
* was genuinely taken and genuinely returned, and cancelling the receipt instead would leave the
* day's cash movements unaccounted for in both directions.
*
* A failure here does not fail the rejection, for the same reason it does not fail an issue: the
* refund is already posted, and holding a rejection over a slip would leave the purchaser's money
* in suspense with no way out.
*/
private void raiseCashRefundVoucher(final Long issueRequestId, final BigDecimal total, final Long cashSuspenseGlAccountId,
final Long cashGlAccountId, final AppUser currentUser) {
try {
final List<VoucherLine> lines = new ArrayList<>();
final Map<String, Object> suspense = this.voucherService.glAccount(cashSuspenseGlAccountId);
lines.add(VoucherLine
.debitGl(cashSuspenseGlAccountId, (String) suspense.get("name"), (String) suspense.get("gl_code"), total)
.withDescription("DD request #" + issueRequestId + " rejected"));
final Map<String, Object> cash = this.voucherService.glAccount(cashGlAccountId);
lines.add(VoucherLine.creditGl(cashGlAccountId, (String) cash.get("name"), (String) cash.get("gl_code"), total)
.withDescription("Cash refunded to purchaser"));
this.voucherService.createVoucherWithLines(VoucherService.TYPE_DISBURSEMENT, REF_SPONSOR_DD_ISSUE, issueRequestId,
currentUser, VoucherService.PREFIX_DEBIT, lines);
} catch (final RuntimeException e) {
log.error("Could not raise a cash refund voucher for Sponsor DD issue request {}", issueRequestId, e);
}
}
/**
* Records an approved Demand Draft issue in the voucher register.
*
@@ -1232,6 +1428,12 @@ public class SponsorDDIssueRequestWritePlatformServiceImpl implements SponsorDDI
* A failure here does not fail the issue. The draft is already raised, the leaflet already
* consumed and the ledger already posted; withdrawing that over a slip would take back a
* completed instrument for a paperwork reason. The problem is logged and the issue stands.
*
* Raised as a payment (DR), whichever way the draft was funded. For a DD funded from the
* purchaser's account or by cheque that is what it is — the money leaves at approval. For one
* paid in cash the notes were receipted at submit and approval only moves them out of suspense,
* so the register keeps two slips over the same money: a CR when it came in and this DR when it
* was applied. Read the two together, not the DR alone, when reconciling a day's cash.
*/
private void raiseIssueVoucher(final Long issueRequestId, final String ddNumber, final Long fundingGlAccountId,
final Long ddPayableGlAccountId, final BigDecimal faceValue, final BigDecimal chargesAmount, final Long chargesGlAccountId,
@@ -24,6 +24,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
public interface SponsorDDReadPlatformService {
@@ -58,6 +59,11 @@ public interface SponsorDDReadPlatformService {
Collection<SponsorDDReprintRequestData> retrieveAllReprintRequests(String status);
/** DD amendment requests for the approval queue, optionally narrowed to one status. */
Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(String status);
SponsorDDLeafletData retrieveNextAvailableLeaflet(Long branchId, Long sponsorBankId, Long inventoryId);
/**
* Approved cashier Transaction Entry (AC.TP=GL) deposit requests, credited to the DD Cash
* Suspense GL account, and not yet linked to any Sponsor DD issue request - candidates a clerk
@@ -31,6 +31,7 @@ import org.apache.fineract.portfolio.sponsordd.data.SponsorDDGlMappingData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDInventoryData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDIssueRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDLeafletData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDAmendmentRequestData;
import org.apache.fineract.portfolio.sponsordd.data.SponsorDDReprintRequestData;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -81,7 +82,8 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
+ "leaf.issuing_branch_id, o.name as branch_name, "
+ "leaf.issued_date, leaf.cleared_date, leaf.advice_status, leaf.advice_sent_date, pir.id as pending_issue_request_id, "
+ "leaf.print_count, prr.id as pending_reprint_request_id, arr.id as approved_reprint_request_id, "
+ "apr.checker_id as approver_user_id, approver.username as approver_username "
+ "apr.checker_id as approver_user_id, approver.username as approver_username, "
+ "amr.id as pending_amendment_request_id "
+ "from m_sponsor_dd_leaflet leaf "
+ "left join m_client c on c.id = leaf.issued_to_client_id "
+ "left join m_office o on o.id = leaf.issuing_branch_id "
@@ -89,7 +91,10 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
+ "left join m_sponsor_dd_reprint_request prr on prr.leaflet_id = leaf.id and prr.status = 'PENDING' "
+ "left join m_sponsor_dd_reprint_request arr on arr.leaflet_id = leaf.id and arr.status = 'APPROVED' and arr.consumed = false "
+ "left join m_sponsor_dd_issue_request apr on apr.leaflet_id = leaf.id and apr.status = 'APPROVED' "
+ "left join m_appuser approver on approver.id = apr.checker_id ";
+ "left join m_appuser approver on approver.id = apr.checker_id "
// Lets the registry show that a correction is already awaiting a manager, instead of
// offering Edit again and letting a second request be raised over the same draft.
+ "left join m_sponsor_dd_amendment_request amr on amr.leaflet_id = leaf.id and amr.status = 'PENDING' ";
@Override
public Collection<SponsorDDLeafletData> retrieveAllLeafletsByInventory(final Long inventoryId) {
@@ -163,11 +168,13 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
final Long approvedUnconsumedReprintRequestId = rs.getObject("approved_reprint_request_id") != null ? rs.getLong("approved_reprint_request_id") : null;
final Long approverUserId = rs.getObject("approver_user_id") != null ? rs.getLong("approver_user_id") : null;
final String approverUsername = rs.getString("approver_username");
final Long pendingAmendmentRequestId = rs.getObject("pending_amendment_request_id") != null
? rs.getLong("pending_amendment_request_id") : null;
return new SponsorDDLeafletData(id, inventoryId, ddNumber, status, faceValue, issuedToClientId, clientName,
walkinPurchaserName, walkinPurchaserAddress, walkinPurchaserAadhaarMasked, beneficiaryName, payableAtBranch,
payableAtCity, issuingBranchId, branchName, issuedDate, clearedDate, adviceStatus, adviceSentDate,
pendingIssueRequestId, printCount, pendingReprintRequestId, approvedUnconsumedReprintRequestId,
approverUserId, approverUsername);
approverUserId, approverUsername, pendingAmendmentRequestId);
}
}
@@ -231,10 +238,12 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
private static final String GL_MAPPING_SELECT =
"select m.id, null as sponsor_bank_id, 'Global' as sponsor_bank_name, m.dd_payable_gl_account_id, "
+ "payable.name as dd_payable_gl_account_name, m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, "
+ "m.cash_gl_account_id, cash.name as cash_gl_account_name, m.charges_income_gl_account_id, charges.name as charges_income_gl_account_name, "
+ "m.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, "
+ "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, "
+ "payable.name as dd_payable_gl_account_name, payable.gl_code as dd_payable_gl_code, "
+ "m.cheque_clearing_gl_account_id, clearing.name as cheque_clearing_gl_account_name, clearing.gl_code as cheque_clearing_gl_code, "
+ "m.cash_gl_account_id, cash.name as cash_gl_account_name, cash.gl_code as cash_gl_code, "
+ "m.charges_income_gl_account_id, charges.name as charges_income_gl_account_name, charges.gl_code as charges_income_gl_code, "
+ "m.cash_suspense_gl_account_id, suspense.name as cash_suspense_gl_account_name, suspense.gl_code as cash_suspense_gl_code, "
+ "m.gst_percentage, m.gst_payable_gl_account_id, gst.name as gst_payable_gl_account_name, gst.gl_code as gst_payable_gl_code, "
+ "m.dd_limit_amount "
+ "from m_sponsor_dd_gl_mapping m "
+ "join acc_gl_account payable on payable.id = m.dd_payable_gl_account_id "
@@ -268,22 +277,28 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
final String sponsorBankName = rs.getString("sponsor_bank_name");
final Long ddPayableGlAccountId = rs.getLong("dd_payable_gl_account_id");
final String ddPayableGlAccountName = rs.getString("dd_payable_gl_account_name");
final String ddPayableGlCode = rs.getString("dd_payable_gl_code");
final Long chequeClearingGlAccountId = rs.getObject("cheque_clearing_gl_account_id") != null ? rs.getLong("cheque_clearing_gl_account_id") : null;
final String chequeClearingGlAccountName = rs.getString("cheque_clearing_gl_account_name");
final String chequeClearingGlCode = rs.getString("cheque_clearing_gl_code");
final Long cashGlAccountId = rs.getObject("cash_gl_account_id") != null ? rs.getLong("cash_gl_account_id") : null;
final String cashGlAccountName = rs.getString("cash_gl_account_name");
final String cashGlCode = rs.getString("cash_gl_code");
final Long chargesIncomeGlAccountId = rs.getObject("charges_income_gl_account_id") != null ? rs.getLong("charges_income_gl_account_id") : null;
final String chargesIncomeGlAccountName = rs.getString("charges_income_gl_account_name");
final String chargesIncomeGlCode = rs.getString("charges_income_gl_code");
final Long cashSuspenseGlAccountId = rs.getObject("cash_suspense_gl_account_id") != null ? rs.getLong("cash_suspense_gl_account_id") : null;
final String cashSuspenseGlAccountName = rs.getString("cash_suspense_gl_account_name");
final String cashSuspenseGlCode = rs.getString("cash_suspense_gl_code");
final BigDecimal gstPercentage = rs.getBigDecimal("gst_percentage");
final Long gstPayableGlAccountId = rs.getObject("gst_payable_gl_account_id") != null ? rs.getLong("gst_payable_gl_account_id") : null;
final String gstPayableGlAccountName = rs.getString("gst_payable_gl_account_name");
final String gstPayableGlCode = rs.getString("gst_payable_gl_code");
final BigDecimal ddLimitAmount = rs.getBigDecimal("dd_limit_amount");
return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName,
chequeClearingGlAccountId, chequeClearingGlAccountName, cashGlAccountId, cashGlAccountName,
chargesIncomeGlAccountId, chargesIncomeGlAccountName, cashSuspenseGlAccountId, cashSuspenseGlAccountName,
gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, ddLimitAmount);
return new SponsorDDGlMappingData(id, sponsorBankId, sponsorBankName, ddPayableGlAccountId, ddPayableGlAccountName, ddPayableGlCode,
chequeClearingGlAccountId, chequeClearingGlAccountName, chequeClearingGlCode, cashGlAccountId, cashGlAccountName, cashGlCode,
chargesIncomeGlAccountId, chargesIncomeGlAccountName, chargesIncomeGlCode, cashSuspenseGlAccountId, cashSuspenseGlAccountName, cashSuspenseGlCode,
gstPercentage, gstPayableGlAccountId, gstPayableGlAccountName, gstPayableGlCode, ddLimitAmount);
}
}
@@ -420,6 +435,57 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
}
}
private static final String AMENDMENT_REQUEST_SELECT =
"select ar.id, ar.leaflet_id, leaf.dd_number, ar.beneficiary_name, ar.payable_at_branch, ar.payable_at_city, "
+ "ar.old_beneficiary_name, ar.old_payable_at_branch, ar.old_payable_at_city, "
+ "ar.reason, ar.status, ar.maker_id, maker.username as maker_username, ar.created_date, "
+ "ar.checker_id, checker.username as checker_username, ar.checker_date, ar.rejection_reason "
+ "from m_sponsor_dd_amendment_request ar "
+ "join m_sponsor_dd_leaflet leaf on leaf.id = ar.leaflet_id "
+ "join m_appuser maker on maker.id = ar.maker_id "
+ "left join m_appuser checker on checker.id = ar.checker_id ";
@Override
public Collection<SponsorDDAmendmentRequestData> retrieveAllAmendmentRequests(final String status) {
if (status != null && !status.isBlank()) {
final String sql = AMENDMENT_REQUEST_SELECT + "where ar.status = ? order by ar.id desc";
return this.jdbcTemplate.query(sql, new AmendmentRequestMapper(), status);
}
final String sql = AMENDMENT_REQUEST_SELECT + "order by ar.id desc";
return this.jdbcTemplate.query(sql, new AmendmentRequestMapper());
}
private static final class AmendmentRequestMapper implements RowMapper<SponsorDDAmendmentRequestData> {
@Override
public SponsorDDAmendmentRequestData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long leafletId = rs.getLong("leaflet_id");
final String ddNumber = rs.getString("dd_number");
final String beneficiaryName = rs.getString("beneficiary_name");
final String payableAtBranch = rs.getString("payable_at_branch");
final String payableAtCity = rs.getString("payable_at_city");
final String oldBeneficiaryName = rs.getString("old_beneficiary_name");
final String oldPayableAtBranch = rs.getString("old_payable_at_branch");
final String oldPayableAtCity = rs.getString("old_payable_at_city");
final String reason = rs.getString("reason");
final String status = rs.getString("status");
final Long makerId = rs.getLong("maker_id");
final String makerUsername = rs.getString("maker_username");
final LocalDateTime createdDate = rs.getTimestamp("created_date") != null
? rs.getTimestamp("created_date").toLocalDateTime() : null;
final Long checkerId = rs.getObject("checker_id") != null ? rs.getLong("checker_id") : null;
final String checkerUsername = rs.getString("checker_username");
final LocalDateTime checkerDate = rs.getTimestamp("checker_date") != null
? rs.getTimestamp("checker_date").toLocalDateTime() : null;
final String rejectionReason = rs.getString("rejection_reason");
return new SponsorDDAmendmentRequestData(id, leafletId, ddNumber, beneficiaryName, payableAtBranch, payableAtCity,
oldBeneficiaryName, oldPayableAtBranch, oldPayableAtCity, reason, status, makerId, makerUsername, createdDate,
checkerId, checkerUsername, checkerDate, rejectionReason);
}
}
@Override
public Collection<SponsorDDLeafletData> retrieveLeaflets(final Long sponsorBankId, final String status, final String adviceStatus) {
final List<Object> params = new ArrayList<>();
@@ -440,6 +506,35 @@ public class SponsorDDReadPlatformServiceImpl implements SponsorDDReadPlatformSe
return this.jdbcTemplate.query(sql, this.leafletMapper, params.toArray());
}
@Override
public SponsorDDLeafletData retrieveNextAvailableLeaflet(final Long branchId, final Long sponsorBankId, final Long inventoryId) {
final StringBuilder sql = new StringBuilder(LEAFLET_SELECT);
sql.append("join m_sponsor_dd_inventory inv on inv.id = leaf.inventory_id ");
sql.append("where leaf.status = 'AVAILABLE' ");
sql.append("and leaf.id not in (select leaflet_id from m_sponsor_dd_issue_request where status = 'PENDING') ");
final List<Object> params = new java.util.ArrayList<>();
if (inventoryId != null) {
sql.append("and inv.id = ? ");
params.add(inventoryId);
}
if (branchId != null) {
sql.append("and inv.branch_id = ? ");
params.add(branchId);
}
if (sponsorBankId != null) {
sql.append("and inv.sponsor_bank_id = ? ");
params.add(sponsorBankId);
}
sql.append("order by inv.id asc, leaf.dd_number asc limit 1");
try {
return this.jdbcTemplate.queryForObject(sql.toString(), this.leafletMapper, params.toArray());
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
@Override
public Collection<SponsorDDCashierDepositOptionData> retrieveAvailableCashierDepositRequests() {
final String sql = "select cdr.id, cdr.voucher_no, cdr.amount, cdr.description, cdr.cashier_username, cdr.actioned_at, "
@@ -434,7 +434,8 @@ public class VoucherVerificationApiResource {
@GET
@Path("report")
public String report(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType) {
@QueryParam("username") final String username, @QueryParam("voucherType") final String voucherType,
@QueryParam("cashOnly") final Boolean cashOnly) {
final AppUser currentUser = this.context.authenticatedUser();
final boolean seesEveryone = isCheckerRole(currentUser);
@@ -460,7 +461,8 @@ public class VoucherVerificationApiResource {
ver.status AS verification_status,
dr.party_name AS debit_party, dr.account_no AS debit_account,
cr.party_name AS credit_party, cr.account_no AS credit_account,
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count
(SELECT COUNT(*) FROM m_voucher_line l WHERE l.voucher_no = v.voucher_no) AS line_count,
cash.moved AS is_cash
FROM m_voucher v
LEFT JOIN m_office o ON o.id = v.branch_office_id
LEFT JOIN m_voucher_verification ver ON ver.voucher_no = v.voucher_no
@@ -472,6 +474,52 @@ public class VoucherVerificationApiResource {
SELECT party_name, account_no FROM m_voucher_line
WHERE voucher_no = v.voucher_no AND entry_type = 'CR'
ORDER BY line_no LIMIT 1) cr ON TRUE
-- A Demand Draft counts its notes at submission and keys them by the request id,
-- then re-keys them to the resulting journal entry once the draft is approved,
-- while the voucher keeps pointing at the request. Neither id alone finds the
-- notes for the whole life of the draft, so the request is joined to supply the
-- second one.
LEFT JOIN m_sponsor_dd_issue_request dd
ON v.reference_type = 'SPONSOR_DD_ISSUE_REQUEST' AND dd.id = v.reference_id
-- Whether notes and coins were counted against this voucher. A denomination row
-- is written only when money was physically counted, so its presence proves cash;
-- its absence proves nothing, because several registers that genuinely pay out
-- cash record no breakdown at all (the clerk disbursement register and the loan
-- module write none, and a clerk withdrawal has none until it is disbursed).
--
-- Each register keys these rows by an id of its own, all sharing one txn_id
-- column, so every branch is tied to the reference type that wrote it: an
-- unqualified match would let a deposit request borrow the notes counted for an
-- unrelated savings transaction of the same id.
LEFT JOIN LATERAL (
SELECT TRUE AS moved FROM m_savings_txn_denomination d
WHERE d.txn_id = v.savings_txn_id
OR (v.reference_type IN ('SAVINGS_TRANSACTION', 'CASHIER_DEPOSIT_REQUEST',
'CLERK_WITHDRAWAL_REQUEST', 'SPONSOR_DD_ISSUE_REQUEST')
AND d.txn_id = v.reference_id)
OR d.txn_id = dd.resulting_journal_entry_id
LIMIT 1) cash ON TRUE
-- Whether either side of the voucher is the till itself.
--
-- This is what separates money crossing the counter from a book entry between two
-- ledger accounts. A DD shows why it is needed: taking the purchaser's notes debits
-- Cash at Vault, so it is a real receipt, but approving the draft afterwards only
-- moves the amount from suspense to DD Payable -- no cash, no cashier, yet it
-- carries a DR number and was landing on the payment report as cash paid out.
--
-- Two shapes have to be recognised. Registers that know their GL write a real
-- gl_account_id, matched against the cash-at-teller and cash-at-vault financial
-- activities (102 and 101). The counter registers do not carry a GL id for the
-- drawer at all and write the line named 'Cash in Hand' with a null id, so that
-- name is accepted as the marker it is used as.
LEFT JOIN LATERAL (
SELECT TRUE AS touched FROM m_voucher_line cl
WHERE cl.voucher_no = v.voucher_no
AND (cl.gl_account_id IN (
SELECT fa.gl_account_id FROM acc_gl_financial_activity_account fa
WHERE fa.financial_activity_type IN (101, 102))
OR (cl.gl_account_id IS NULL AND cl.party_name = 'Cash in Hand'))
LIMIT 1) till ON TRUE
WHERE v.created_at >= ? AND v.created_at < ?
""");
@@ -489,6 +537,32 @@ public class VoucherVerificationApiResource {
sql.append(" AND v.voucher_type = ?");
args.add(voucherType.trim());
}
// A cashier's register is cash: what came over the counter in notes and coins.
//
// The test is whether a side of the voucher is the till (the `till` join above). That is
// what tells a receipt apart from a book entry between two ledger accounts, and it is the
// only test that gets a Demand Draft right: the same draft raises a genuine cash receipt
// when the notes are taken and a pure suspense-to-payable transfer when it is approved,
// both under the same reference type, and only the ledger accounts distinguish them.
//
// A voucher with no lines at all is kept. The registers attach their sides at approval, so
// a voucher raised and not yet actioned has none, and dropping those would hide real money
// from the report during the very window in which someone is looking for it. Under-reporting
// cash is the worse failure: a book entry on the register is visible and can be recognised,
// whereas cash that never appears surfaces only as an unexplained difference at the close.
//
// The type and reference exclusions below are kept for entries that carry a cash line yet
// still are not counter work -- interest posted into an account, a cheque settling between
// banks, a fee debited to a member, an RTGS leaving the bank's own cash account.
final boolean restrictToCash = Boolean.TRUE.equals(cashOnly);
if (restrictToCash) {
sql.append(" AND (till.touched IS TRUE OR NOT EXISTS ("
+ "SELECT 1 FROM m_voucher_line nl WHERE nl.voucher_no = v.voucher_no))");
sql.append(" AND v.voucher_type <> 'INTEREST_POSTING'");
sql.append(" AND v.voucher_no NOT LIKE 'JV/%'");
sql.append(" AND (v.reference_type IS NULL OR v.reference_type NOT IN ("
+ "'CLEARING_POSTING', 'CHEQUEBOOK_CHARGE', 'BANK_TRANSFER_LOG'))");
}
sql.append(" ORDER BY v.created_at DESC, v.voucher_no DESC");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
@@ -518,6 +592,10 @@ public class VoucherVerificationApiResource {
// so the row says how much it is hiding rather than misrepresenting the voucher.
final int lineCount = ((Number) r.get("line_count")).intValue();
m.put("extraLines", Math.max(0, lineCount - 2));
// True when notes and coins were counted against this voucher. False means unconfirmed,
// not non-cash: the registers that record no breakdown still pay real money out. The
// screen marks these rather than dropping them.
m.put("denominationCounted", Boolean.TRUE.equals(r.get("is_cash")));
out.add(m);
}
@@ -528,6 +606,7 @@ public class VoucherVerificationApiResource {
// Tells the screen whether to offer the user filter at all, rather than having it guess
// from the caller's roles a second time.
res.put("canSeeAllUsers", seesEveryone);
res.put("cashOnly", restrictToCash);
res.put("vouchers", out);
res.put("count", out.size());
res.put("totalAmount", total);
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<?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
@@ -8,16 +7,13 @@
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"
@@ -586,4 +582,17 @@
<include file="parts/0474_enable_fixed_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0475_enable_admin_user_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0476_add_user_pending_request_and_enable_charge_mc.xml" relativeToChangelogFile="true"/>
<include file="parts/0520_add_kyc_expiry_restrictions_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0521_rbi_dormancy_defaults.xml" relativeToChangelogFile="true"/>
<include file="parts/0522_create_deaf_remittance.xml" relativeToChangelogFile="true"/>
<include file="parts/0523_dbt_dormancy_exemption.xml" relativeToChangelogFile="true"/>
<include file="parts/0524_rbi_dormancy_periods_all_products.xml" relativeToChangelogFile="true"/>
<include file="parts/0525_savings_daily_interest_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0526_savings_interest_summary_view.xml" relativeToChangelogFile="true"/>
<include file="parts/0527_savings_daily_interest_one_row_per_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0528_fix_escheat_liability_mapping.xml" relativeToChangelogFile="true"/>
<include file="parts/0529_add_denomination_data_to_sponsor_dd_issue_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0530_drop_dbt_dormancy_exemption.xml" relativeToChangelogFile="true"/>
<include file="parts/0531_disable_stock_savings_interest_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0532_sponsor_dd_amendment_request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -108,6 +108,11 @@
next_posting_date is deliberately left null: the first run works it out from the posting
period and the financial year start, and fills it in. Seeding a guess here would put a
wrong date in front of somebody before the job has ever run.
NOTE: this seeds only the products that existed when it ran. A product created afterwards is
enrolled by SavingsDailyInterestOrchestrator.enrolNewProducts() on the next run, and 0525
catches up any that were left behind in between. Do not edit this changeset to widen it —
it has already run, and changing it breaks the checksum.
-->
<changeSet id="0518_seed_savings_posting_schedule_rows" author="CBS">
<sql>
@@ -0,0 +1,52 @@
<?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.9.xsd">
<!--
Registers the nightly pass that applies the RBI re-KYC restriction ladder: debits stop 90
days past the due date, the account goes inoperative at 180, and everything is released once
the KYC is renewed. Scheduled just after midnight so the day opens with restrictions correct.
-->
<changeSet author="fineract" id="1">
<insert tableName="job">
<column name="name" value="Apply KYC Expiry Restrictions"/>
<column name="display_name" value="Apply KYC Expiry Restrictions"/>
<!-- short_name is NOT NULL and capped at 8 characters; see 0145_job_short_name.xml. -->
<column name="short_name" value="KYC_EXPR"/>
<column name="cron_expression" value="0 10 0 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Apply KYC Expiry RestrictionsJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,72 @@
<?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.9.xsd">
<!--
Turns dormancy tracking on with the periods RBI works to.
The columns ship as NULL, and the nightly "Update Savings Dormant Accounts" job joins on
is_dormancy_tracking_active = true, so until a bank fills these in no account ever becomes
inoperative — the tracking is built but idle. These are the RBI periods, counted from the
last customer-induced transaction:
365 days -> inactive (an internal flag; nothing is restricted)
730 days -> dormant (2 years; debits stop, reactivation needs the branch)
3650 days -> escheat (10 years; the balance is due to DEAF)
Only savings products are touched (deposit_type_enum = 100). A term or recurring deposit is
not expected to see customer transactions between its opening and its maturity, so treating
that silence as dormancy would put every healthy FD into an inoperative state.
Products where someone has already set their own periods are left exactly as they are: a
configured value is a decision, and this migration is a default, not a correction.
-->
<changeSet author="fineract" id="1">
<update tableName="m_savings_product">
<column name="is_dormancy_tracking_active" valueBoolean="true"/>
<column name="days_to_inactive" valueNumeric="365"/>
<column name="days_to_dormancy" valueNumeric="730"/>
<column name="days_to_escheat" valueNumeric="3650"/>
<where>deposit_type_enum = 100 AND (is_dormancy_tracking_active IS NULL OR is_dormancy_tracking_active = false)
AND days_to_inactive IS NULL AND days_to_dormancy IS NULL AND days_to_escheat IS NULL</where>
</update>
</changeSet>
<!--
The same periods for current accounts, which carry their own product table and are equally
subject to the inoperative rules.
-->
<changeSet author="fineract" id="2">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_current_product"/>
</preConditions>
<update tableName="m_current_product">
<column name="is_dormancy_tracking_active" valueBoolean="true"/>
<column name="days_to_inactive" valueNumeric="365"/>
<column name="days_to_dormancy" valueNumeric="730"/>
<column name="days_to_escheat" valueNumeric="3650"/>
<where>(is_dormancy_tracking_active IS NULL OR is_dormancy_tracking_active = false)
AND days_to_inactive IS NULL AND days_to_dormancy IS NULL AND days_to_escheat IS NULL</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,77 @@
<?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.9.xsd">
<!--
Records that an escheated balance actually reached RBI's DEAF.
Escheat and remittance are separate events: the nightly job closes the account and removes the
balance, but sending that money to RBI happens outside this system. Without this table the
escheat report cannot tell a remitted balance from one still sitting with the bank, and money
taken out of customers' accounts can drift unaccounted for.
One row per savings account — a balance goes to the fund once. refunded_on covers the case
where the customer later claims it and the bank recovers the amount from the fund.
-->
<changeSet author="fineract" id="1">
<createTable tableName="m_deaf_remittance">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints primaryKey="true"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_deaf_remittance_savings_account"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="escheated_on" type="DATE">
<constraints nullable="false"/>
</column>
<column name="remitted_on" type="DATE">
<constraints nullable="false"/>
</column>
<column name="deaf_reference" type="VARCHAR(100)"/>
<column name="refunded_on" type="DATE"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="recorded_by_user_id" type="BIGINT"/>
<column name="recorded_on" type="DATETIME">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseColumnNames="savings_account_id" baseTableName="m_deaf_remittance"
constraintName="fk_deaf_remittance_savings_account"
referencedColumnNames="id" referencedTableName="m_savings_account"/>
<addForeignKeyConstraint baseColumnNames="client_id" baseTableName="m_deaf_remittance"
constraintName="fk_deaf_remittance_client"
referencedColumnNames="id" referencedTableName="m_client"/>
<createIndex indexName="idx_deaf_remittance_remitted_on" tableName="m_deaf_remittance">
<column name="remitted_on"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,57 @@
<?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.9.xsd">
<!--
Marks an account as receiving government benefit credits, which exempts it from dormancy.
RBI directs that an account credited with DBT, pension, scholarship or similar benefit
transfers is not to be classified inoperative for want of customer transactions. The reason is
practical: the holder of a pension or subsidy account often does nothing but let the money
arrive, and freezing debits on such an account stops the very payment it exists to receive.
The flag is set on the account rather than inferred from transaction narrations. Narrations
vary by sponsor, channel and file format, so matching on them would exempt accounts by
accident and miss ones that qualify. A branch marking the account is a decision on record.
-->
<changeSet author="fineract" id="1">
<addColumn tableName="m_savings_account">
<column name="is_dbt_beneficiary" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<!--
Releases any dormancy state already applied to an account since marked as a DBT beneficiary.
Only the dormancy sub-statuses are cleared (100 inactive, 200 dormant) — an escheated account
has had its balance removed and cannot be undone here, and a manual block is somebody's
deliberate act.
-->
<changeSet author="fineract" id="2">
<update tableName="m_savings_account">
<column name="sub_status_enum" valueNumeric="0"/>
<where>is_dbt_beneficiary = true AND sub_status_enum IN (100, 200)</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,77 @@
<?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.9.xsd">
<!--
Brings every product's dormancy periods onto the RBI schedule.
0521 set these only where all three columns were still NULL, on the reasoning that a
configured value was somebody's decision. That left the products carrying short trial values
— a few weeks rather than a year — which are not decisions but leftovers, and they make
healthy accounts show as overdue for inactivity within a month of their last transaction.
RBI counts from the last customer-induced transaction: 365 days to inactive, 730 to
inoperative, 3650 to DEAF. Anything materially shorter is not a policy choice a bank could
defend, so every savings and current product is brought to these periods.
Term and recurring deposits keep whatever they have: an FD sees no customer transaction
between opening and maturity, so treating that silence as dormancy would put every healthy
one into an inoperative state.
-->
<changeSet author="fineract" id="1">
<update tableName="m_savings_product">
<column name="is_dormancy_tracking_active" valueBoolean="true"/>
<column name="days_to_inactive" valueNumeric="365"/>
<column name="days_to_dormancy" valueNumeric="730"/>
<column name="days_to_escheat" valueNumeric="3650"/>
<where>deposit_type_enum = 100</where>
</update>
</changeSet>
<changeSet author="fineract" id="2">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_current_product"/>
</preConditions>
<update tableName="m_current_product">
<column name="is_dormancy_tracking_active" valueBoolean="true"/>
<column name="days_to_inactive" valueNumeric="365"/>
<column name="days_to_dormancy" valueNumeric="730"/>
<column name="days_to_escheat" valueNumeric="3650"/>
</update>
</changeSet>
<!--
Clears dormancy that the short periods had already applied. An account marked inactive or
dormant because a three-week threshold elapsed is not dormant under the RBI periods, and
leaving the sub-status set would keep it restricted on a rule that no longer exists.
Escheat is not reversed — that balance has left the account — and a manual block stays,
since neither was applied by the dormancy job.
-->
<changeSet author="fineract" id="3">
<update tableName="m_savings_account">
<column name="sub_status_enum" valueNumeric="0"/>
<where>sub_status_enum IN (100, 200)</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,74 @@
<?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.9.xsd">
<!--
Registers the job that runs the daily-interest ledger.
The orchestrator was only reachable through its API, so accrual ran for as long as somebody
kept calling it and posting happened only when somebody remembered. Interest a bank owes its
depositors cannot depend on that.
Scheduled at 00:30 to match the run_at_time the posting schedule already carries, and after
the dormancy and KYC jobs so the day's account states are settled before interest is paid.
-->
<changeSet author="fineract" id="1">
<insert tableName="job">
<column name="name" value="Savings Daily Interest Accrual And Posting"/>
<column name="display_name" value="Savings Daily Interest Accrual And Posting"/>
<column name="short_name" value="SA_DINT"/>
<column name="cron_expression" value="0 30 0 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Savings Daily Interest Accrual And PostingJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
<!--
Enrols the savings products created since 0518 seeded this table. The orchestrator now enrols
new products on every run, but doing it here as well means the products already sitting with
unposted accruals are picked up on the first run rather than the second.
-->
<changeSet author="fineract" id="2">
<sql>
INSERT INTO m_savings_interest_posting_schedule
(savings_product_id, posting_period_enum, run_at_time, is_active)
SELECT sp.id, sp.interest_posting_period_enum, TIME '00:30:00', true
FROM m_savings_product sp
WHERE sp.deposit_type_enum = 100
AND sp.interest_posting_period_enum IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM m_savings_interest_posting_schedule s
WHERE s.savings_product_id = sp.id)
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,97 @@
<?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.9.xsd">
<!--
One row per savings account, carrying everything the daily ledger knows about it.
m_savings_daily_interest is a day-by-day record and has to stay that way: interest is worked
out on the balance each day actually carried, an audit asks what a given day earned, and a
reversed backdated entry means every day after it is recomputed. None of that survives being
collapsed into a single stored row.
What a reader usually wants, though, is the current position — this account, this cycle, this
much accrued — without reading thirty rows to find it. That is what this view is: the latest
day's figures per account, with the cycle totals and the lifetime posted total alongside, so
a screen or a report can select one row per account and be done.
The cumulative figure resets on its own: cycle_cumulative_interest counts from the cycle's
start, so the day after a posting it begins again at that day's interest.
-->
<changeSet author="fineract" id="1">
<createView viewName="v_savings_interest_summary" replaceIfExists="true">
SELECT
d.savings_account_id AS savings_account_id,
sa.account_no AS account_no,
sa.client_id AS client_id,
c.display_name AS client_name,
o.name AS office_name,
d.savings_product_id AS savings_product_id,
sp.name AS product_name,
sa.currency_code AS currency_code,
d.balance_date AS as_on_date,
d.opening_balance AS opening_balance,
d.closing_balance AS closing_balance,
d.daily_interest AS interest_today,
d.cycle_number AS cycle_number,
d.cycle_start_date AS cycle_start_date,
d.cycle_end_date AS cycle_end_date,
d.cycle_cumulative_interest AS interest_this_cycle,
(d.cycle_end_date - d.balance_date) AS days_to_posting,
d.is_posting_day AS is_posting_day,
d.posting_date AS posting_date,
d.interest_rate AS interest_rate,
d.days_in_year AS days_in_year,
d.compounding_period_enum AS compounding_period_enum,
d.posting_period_enum AS posting_period_enum,
d.calculation_type_enum AS calculation_type_enum,
d.min_balance_for_interest AS min_balance_for_interest,
d.below_min_balance AS below_min_balance,
COALESCE(t.posted_total, 0) AS interest_posted_total,
t.last_posted_on AS last_posted_on,
COALESCE(t.days_accrued, 0) AS days_accrued,
d.last_recomputed_on AS last_recomputed_on
FROM m_savings_daily_interest d
JOIN m_savings_account sa ON sa.id = d.savings_account_id
JOIN m_savings_product sp ON sp.id = d.savings_product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN (
SELECT savings_account_id,
SUM(COALESCE(posted_amount, 0)) AS posted_total,
MAX(posting_date) FILTER (WHERE savings_transaction_id IS NOT NULL) AS last_posted_on,
COUNT(*) AS days_accrued
FROM m_savings_daily_interest
GROUP BY savings_account_id
) t ON t.savings_account_id = d.savings_account_id
WHERE d.balance_date = (
SELECT MAX(balance_date) FROM m_savings_daily_interest
WHERE savings_account_id = d.savings_account_id
)
</createView>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,92 @@
<?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.9.xsd">
<!--
Makes the ledger hold one row per savings account instead of one per account per day.
The running total each row carries (cycle_cumulative_interest) is what the posting reads, and
it is complete on its own: the day's interest is added to it and the row is rewritten, so a
single row states where the account stands without the days behind it.
What this gives up is worth stating, because it cannot be recovered later:
- the balance each day carried. Interest is worked out on it, so a recomputation after the
fact can only use today's balance for every day, which is wrong wherever the balance moved.
- the answer to "what did this account earn on 15 August", which an audit can ask.
- repairing a reversed backdated transaction. Rebuilding from the day of the reversal needs
each day's balance, and after this only the latest is held.
The rows already accrued are kept in m_savings_daily_interest_history_backup so what has been
earned so far is still answerable. Nothing writes to that table; it is a record of what the
ledger held before this change.
-->
<!--
One row per account: the uniqueness that used to include the date now excludes it.
Dropped as a constraint rather than an index — it was created as a unique constraint, and the
index behind it cannot be dropped on its own.
-->
<changeSet author="fineract" id="1">
<preConditions onFail="MARK_RAN">
<indexExists indexName="uq_savings_daily_interest_account_date"/>
</preConditions>
<dropUniqueConstraint tableName="m_savings_daily_interest"
constraintName="uq_savings_daily_interest_account_date"/>
</changeSet>
<!--
Collapse anything still holding more than one row per account, so the new constraint can be
created. The latest day is kept: it is the one carrying the current cycle's running total.
-->
<changeSet author="fineract" id="2">
<sql>
DELETE FROM m_savings_daily_interest d
WHERE d.balance_date &lt; (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x
WHERE x.savings_account_id = d.savings_account_id)
</sql>
</changeSet>
<changeSet author="fineract" id="3">
<addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id"
constraintName="uq_savings_daily_interest_account"/>
</changeSet>
<!--
The product/date index existed to find a product's rows for one date. With one row per account
the date adds nothing to it, and the posting query now looks up by product alone.
-->
<changeSet author="fineract" id="4">
<preConditions onFail="MARK_RAN">
<indexExists indexName="idx_sdi_product_date"/>
</preConditions>
<dropIndex indexName="idx_sdi_product_date" tableName="m_savings_daily_interest"/>
</changeSet>
<changeSet author="fineract" id="5">
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
<column name="savings_product_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,51 @@
<?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.9.xsd">
<!--
Points every product's escheat mapping at the escheat liability account.
Escheat moves a balance unclaimed for ten years out of the customer's account, and it is owed
to RBI's DEAF until remitted. Products were carrying deposit and tax accounts here instead —
GST Payable, Savings Individual, Current Account — so an escheated balance would land in a
liability that belongs to something else entirely, and the amount owed to the fund could not
be read off the ledger at all.
200904 Escheat Liability Account already exists and is the account meant for this. Only the
escheat mapping (financial_account_type 14) is touched, and only where it points somewhere
other than that account.
-->
<changeSet author="fineract" id="1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">
SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '200904'
</sqlCheck>
</preConditions>
<sql>
UPDATE acc_product_mapping
SET gl_account_id = (SELECT id FROM acc_gl_account WHERE gl_code = '200904')
WHERE financial_account_type = 14
AND gl_account_id &lt;&gt; (SELECT id FROM acc_gl_account WHERE gl_code = '200904')
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,38 @@
<?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">
<changeSet author="cbs" id="0529-1-add-denomination-data-to-sponsor-dd-issue-request">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_sponsor_dd_issue_request" />
<not>
<columnExists tableName="m_sponsor_dd_issue_request" columnName="denomination_data" />
</not>
</preConditions>
<addColumn tableName="m_sponsor_dd_issue_request">
<column name="denomination_data" type="TEXT">
<constraints nullable="true" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Removes the per-account government-benefit exemption added in 0523.
The exemption marked an account as receiving DBT, pension or scholarship credits and kept it
out of the dormancy ladder and the KYC-expiry restrictions. It has been withdrawn: the flag,
the endpoint that set it, and the query filters that honoured it are all gone, so the column
would only sit there unread and imply a protection nothing applies.
0523 itself is left alone — it has already run, and editing an applied changeset breaks its
checksum. This undoes it forward instead.
Nothing is lost with the column: no account in this tenant was ever flagged. Should the
exemption be wanted again, it is the flag that has to come back, not data.
-->
<changeSet author="fineract" id="1">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_savings_account" columnName="is_dbt_beneficiary"/>
</preConditions>
<dropColumn tableName="m_savings_account" columnName="is_dbt_beneficiary"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,47 @@
<?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.9.xsd">
<!--
Stops the platform's own savings interest job, leaving the daily-interest ledger as the only
thing that pays savings interest.
Both were running nightly, minutes apart, and they do not agree. The stock job pays a day at
a time and takes no account of the product's posting period: a product configured quarterly
was paid on 135 separate dates, which is neither quarterly nor reconcilable against one. The
ledger job posts on the cycle the product actually declares.
Two jobs both crediting the same accounts is the worse problem: interest paid twice for the
same period, with nothing in either to notice the other had already paid it.
Interest keeps being paid — "Savings Daily Interest Accrual And Posting" (0525) does it, on
each product's own rhythm. Re-enabling this one means going back to postings that ignore the
product's configuration.
-->
<changeSet author="fineract" id="1">
<update tableName="job">
<column name="is_active" valueBoolean="false"/>
<where>name = 'Post Interest For Savings'</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,82 @@
<?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.
-->
<!--
Amending an already-issued Demand Draft.
A beneficiary's name or the branch and city a draft is payable at can be written wrong, and the
instrument is already printed and in the purchaser's hands by the time anyone notices. Changing
it is not a clerical edit: the draft is a payment instruction the sponsor bank will honour, so
the correction goes through the same maker-checker step an issue or a reprint does.
Only those three fields. Face value, purchaser and funding are never amended — a draft drawn for
the wrong amount or against the wrong account is cancelled and re-issued, because the money has
already moved and an edit would leave the ledger disagreeing with the instrument.
The old values are kept on the request so the approval queue can show the checker exactly what
is being changed, and so the record survives even after the leaflet itself is updated.
-->
<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">
<changeSet author="cbs" id="0532-1-create-sponsor-dd-amendment-request">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_sponsor_dd_amendment_request" />
</not>
</preConditions>
<createTable tableName="m_sponsor_dd_amendment_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false" />
</column>
<column name="leaflet_id" type="BIGINT">
<constraints nullable="false" />
</column>
<!-- What it is being changed to. -->
<column name="beneficiary_name" type="VARCHAR(200)" />
<column name="payable_at_branch" type="VARCHAR(200)" />
<column name="payable_at_city" type="VARCHAR(200)" />
<!-- What it was, so the checker sees the change rather than only the result. -->
<column name="old_beneficiary_name" type="VARCHAR(200)" />
<column name="old_payable_at_branch" type="VARCHAR(200)" />
<column name="old_payable_at_city" type="VARCHAR(200)" />
<column name="reason" type="VARCHAR(1000)" />
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false" />
</column>
<column name="maker_id" type="BIGINT" />
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP" />
<column name="checker_id" type="BIGINT" />
<column name="checker_date" type="TIMESTAMP" />
<column name="rejection_reason" type="VARCHAR(1000)" />
</createTable>
<createIndex tableName="m_sponsor_dd_amendment_request" indexName="idx_dd_amendment_leaflet">
<column name="leaflet_id" />
</createIndex>
<createIndex tableName="m_sponsor_dd_amendment_request" indexName="idx_dd_amendment_status">
<column name="status" />
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -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);
@@ -354,7 +354,13 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
* 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)
/*
* Initialised to zero rather than left null: the column is NOT NULL with a default of 0, and a
* default only applies when the column is omitted from the INSERT. JPA writes every mapped
* field, so a null here reaches the database as an explicit null and is rejected — which is what
* made every new savings account fail with a data-integrity error.
*/
@Column(name = "total_savings_amount_on_hold_clearing", scale = 6, precision = 19, nullable = false)
private BigDecimal savingsOnHoldAmountClearing = BigDecimal.ZERO;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY)
@@ -4206,6 +4212,72 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return this.sub_status;
}
/**
* Applies the debit block that follows a lapsed periodic re-KYC.
*
* <p>
* Separate from {@link #blockDebits(Integer)} because that path is an officer's deliberate act
* and raises a validation error when the account is not active or is already blocked. This one
* runs unattended over the whole book, where those are ordinary conditions rather than mistakes
* — an already-blocked account simply needs nothing doing — so it reports whether it acted
* instead of throwing.
*
* @return true if the sub-status was changed
*/
public boolean applyKycExpiryDebitBlock() {
if (!SavingsAccountStatusType.ACTIVE.hasStateOf(getStatus())) {
return false;
}
final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status);
// A full block already stops debits, and a credit block plus a debit block is a full block.
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(current) || SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(current)) {
return false;
}
this.sub_status = SavingsAccountSubStatusEnum.BLOCK_CREDIT.hasStateOf(current) ? SavingsAccountSubStatusEnum.BLOCK.getValue()
: SavingsAccountSubStatusEnum.BLOCK_DEBIT.getValue();
return true;
}
/**
* Applies the full block that follows a re-KYC left outstanding past the inoperative threshold.
*
* <p>
* The credits RBI requires be let through — benefit transfers, interest, the bank's own loan
* recoveries — are not covered by this flag and are posted through the paths that bypass the
* sub-status check, so blocking here does not hold them up.
*
* @return true if the sub-status was changed
*/
public boolean applyKycExpiryFullBlock() {
if (!SavingsAccountStatusType.ACTIVE.hasStateOf(getStatus())) {
return false;
}
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(SavingsAccountSubStatusEnum.fromInt(this.sub_status))) {
return false;
}
this.sub_status = SavingsAccountSubStatusEnum.BLOCK.getValue();
return true;
}
/**
* Lifts a block that a lapsed re-KYC put on, once the KYC has been renewed.
*
* <p>
* Only the states this ladder itself sets are cleared. A block an officer applied by hand, or
* one carrying a dormancy or escheat state, is left exactly as it is: this job put none of
* those on and has no business taking them off.
*
* @return true if the sub-status was changed
*/
public boolean releaseKycExpiryBlock() {
final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status);
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(current) || SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(current)) {
this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue();
return true;
}
return false;
}
public void validateForAccountBlock() {
final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus());
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) {
@@ -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);
}
}
}
@@ -54,4 +54,5 @@
<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>