feat(cfcfrms): I4C SOP layer chain, outward transfer ledger, bank holds and RBI 2026 liability
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run

- Layer-aware hold handling per MHA SOP 02.01.2026: prospective holds only on
  first-layer accounts, onward transfers reported by I4C bank code at layer 2+
- IFSC to I4C bank code map (migration 0617, /v1/i4c/cases/ifsc-map)
- Outward NEFT/IMPS/RTGS booked through Outward Suspense and Settlement GLs;
  MRM restorations to other banks via I4C Restoration Payable (migration 0614)
- Bank holds, exclusions, customer liability and show-cause (0615, 0616)
- Book debits honour every hold and the savings debit block; current account
  debit freeze enforced on all debits; channel block guard on fund transfers
- Drops the restoration pool account setting in favour of GL-based booking

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 11:22:47 +05:30
co-authored by Claude Opus 5.5
parent f016bde385
commit e0cbb80ca4
40 changed files with 3771 additions and 153 deletions
+85 -10
View File
@@ -22,14 +22,28 @@ I4C ──POST /complaints (Bearer)─────► bank checks token + sour
find the account (savings, then current)
layer 0 → find the debit, report where the money went
layer ≥1 → hold up to the disputed amount at once,
trace onward debits for any shortfall,
keep the rest as a prospective hold
report where the rest went (next bank, by I4C bank code),
layer 1 only: keep the rest as a prospective hold
▼
bank ──POST <I4C auth>───────────────► I4C token
bank ──POST <I4C response> (Bearer)──► I4C section 6 message: acknowledgement_no, job_id, transactions[]
(outbox: retried with backoff until delivered)
```
### The chain: Bank A → Bank B → Bank C (MHA SOP 02.01.2026, para 6(g), 6(j), 9.1)
- The victim complains on NCRP / 1930. CFCFRMS sends the complaint to the **victim's bank (A, layer 0)** and to the bank that received the money (**B, layer 1**), each with its own layer number. A reports which debit it was and where it went; B holds what is left.
- If B's customer had already sent part of it to **Bank C**, B reports a *Money Transfer To* entry naming C by its **I4C bank code**. CFCFRMS then sends C its own complaint as **layer 2**, and so on "till the complete money trail is established" (SOP 6(j)).
- This CBS plays every role. It reads the layer in each incident:
- **Layer 0:** it traces the victim's debit.
- **Layer 1** (a first-layer account, SOP definition (x)): it holds the disputed amount, reports the onward transfers for what is gone, and keeps a **prospective hold** for the gap. The prospective hold tops up from later credits (SOP 9.1(i)).
- **Layer 2 and later:** it holds what is there and reports onward. It places **no prospective hold**, because SOP 9.1(i) limits that to first-layer accounts.
- **The next bank must be named by its code.** The seeded master knows only some IFSC prefixes, and co-operative banks often use a sponsor bank's IFSC series. Bank Master > *IFSC to I4C Bank Code map* (migration 0617, endpoints `GET/POST /v1/i4c/cases/ifsc-map`) maps a prefix or a full IFSC to the right code. It also lists the IFSCs already reported with code 0, and the dashboard counts them.
- **Money that moved within this bank** (to another savings / current account here, or to a loan) is raised as a linked alert by the Hold Review job, so the officer can act before CFCFRMS sends the next-layer complaint back to us.
- **Repeat pushes:** the same reported transaction (ack, account, layer, RRN, amount) arriving again in a new request finds the earlier hold. It is never held twice.
- **Freeze and digital suspension** need the LEA's direction (order / notice reference, SOP 9.1(i), S.106 BNSS). Without it, only the disputed amount is held.
- The hold detail shows the customer's **other accounts** here, to report as related accounts (SOP 9.1(v)). They are not held automatically.
### Response types produced
| txn_type_id | txn_type | When |
@@ -51,7 +65,7 @@ The Money Transfer To entry carries the payee reference under **both** `rrn` (th
These follow the MHA SOP for NCRP-CFCFRMS (02.01.2026) and RBI's draft KYC Amendment Directions on debit holds (Sep 2026).
- **Only the disputed amount is held** as an amount hold (`AMOUNT_HOLD`). The rest of the balance stays usable. Every debit path honours the hold: counter, transfer, standing instruction, CTS inward and NACH.
- **Prospective hold:** what the balance cannot cover is recorded as a shortfall. The *I4C Hold Review* job tops it up from later credits.
- **Prospective hold** (first-layer accounts only, not nodal / pool / escrow, SOP 9.1(i)): what the balance cannot cover is recorded as a shortfall. The *I4C Hold Review* job tops it up from later credits. At layer 2+ the gap is reported onward and is not held from future credits.
- **Full debit freeze is never automatic.** An officer asks for it (savings only) and a different officer approves it.
- **Review date** is set on every hold: 90 days by default. The register flags holds 15 days before it.
- **Customer intimation deadline:** 24 hours by default ("by the end of the next day"). It is tracked in the register until an officer records how the customer was told.
@@ -157,7 +171,6 @@ In the UI: **CFCFRMS → I4C Live**. Its tabs are Dashboard, Complaints from I4C
| `…ack.duplicate-mode` / `FINERACT_I4C_ACK_DUPLICATE_MODE` | `SAME_JOB` (00 + original job_id) or `CODE_50003`. Default `SAME_JOB`. |
| `…ack.max-future-days` | A `transaction_date` further ahead than this is refused (40005). Default 1. |
| `…hold.review-days`, `…hold.intimation-hours` | Defaults 90 and 24. |
| `…restoration.pool-account-no` / `FINERACT_I4C_RESTORATION_POOL_ACCOUNT` | Bank-owned savings account (of a bank client) that outward MRM restorations leave from. Blank = only restorations to accounts in this bank. |
| `…restoration.fir-threshold` | MRM: above this amount in one complaint the IO must give an FIR. Default 50000. |
| `…simulator.enabled` | Local stand-in for I4C. **Never `true` in production.** |
@@ -184,11 +197,72 @@ To generate the bcrypt hash: `htpasswd -bnBC 10 "" 'the-password' | tr -d ':\n'`
- `m_i4c_intimation_log` (every notice sent, failed or recorded, per hold);
- column `m_i4c_hold.auto_intimation_attempts`;
- permission `CONFIGURE_I4C`.
- **Migration `0617_i4c_ifsc_bank_code_map.xml`:** `m_i4c_ifsc_map` (the bank's own IFSC prefix / full IFSC -> I4C bank code).
- **Migration `0613_i4c_grm_mrm.xml`:**
- `m_i4c_grievance`, `m_i4c_grievance_event` (GRM), `m_i4c_restoration` (MRM, beneficiary and victim bank), `m_i4c_document` (files, with SHA-256);
- columns `m_i4c_hold.released_amount`, `restored_amount`; `m_i4c_hold_action_request.amount`, `restoration_id`, `grievance_id`;
- permissions `MANAGE_I4CGRIEVANCE`, `MANAGE_I4CRESTORATION`.
### Accounting (Organisation > Clearing GL Configuration)
Map these GL roles (migration 0614 adds them unmapped):
| Role | GL (example from the chart) | Use |
|---|---|---|
| `FUNDTRANSFER_OUTWARD_SUSPENSE` | 200502 NEFT OUTWARD SUSPENSE (liability) | money sent, not yet settled |
| `FUNDTRANSFER_SETTLEMENT` | the sponsor bank / RBI current account (asset), e.g. 100201 | settled outward transfers |
| `I4C_RESTORATION_PAYABLE` | a sundry liability, e.g. under 200900 | restored money owed to victims in other banks |
| `I4C_SHADOW_CREDIT` | a receivable (asset) | shadow credits to our customers on reported fraud |
Entries:
- Hold / release: none (a lien on the account, memo only).
- Restore to a victim here: Dr suspect's Savings / Current Control, Cr victim's control (account transfer).
- Restore to another bank: approval Dr suspect's control, Cr I4C Restoration Payable; sending Dr Payable, Cr Outward Suspense; settlement Dr Outward Suspense, Cr Settlement; failure Dr Outward Suspense, Cr Payable.
- Every outward NEFT / IMPS / RTGS (not only I4C) now books the same way once `FUNDTRANSFER_OUTWARD_SUSPENSE` is mapped: Dr payer's Savings Control, Cr Outward Suspense; settlement on SUCCESS; failure / recall credit the payer back. Unmapped, the earlier savings "suspense account" behaviour continues.
- Shadow credit: Dr I4C Shadow Credit, Cr customer's control; regularisation when restored money arrives: Dr customer's control, Cr I4C Shadow Credit.
Also fixed in the CBS core: a book debit (demand draft, outward transfer) now honours every hold and the debit block on savings accounts, and a block on a current account (debit freeze) is enforced on all its debits.
### MHA SOP duties tracked (migration 0614, permission `MANAGE_I4CRISK`)
- **15-day credit** to the victim after the IO's notice; **15-day challenge** by the suspect after the refund.
- **90-day lapse** counted from the bank's GRM grievance; notice to the LEA due 15 days before; an LEA extension recorded; a release "90-day review" without an order is allowed only after the lapse with no extension.
- **Digital banking suspension** on a repeatedly reported account (hold action SUSPEND_DIGITAL / RESTORE_DIGITAL, maker-checker); lifted with the last hold on the account.
- **Mule register**: an account in 2+ complaints is flagged SUSPECTED automatically; confirm or clear with the basis.
- **Fraud reporting** (RBI (UCB - Fraud Risk Management) Directions, 2026, RBI/DoS/2026-27/439, 31.07.2026): opening the case starts the FIU-IND STR (7 days, PML Rules); the account holder gets a show-cause notice with at least **21 days** to reply and a reasoned order before classification; the RBI FMR is then due within **14 days**.
- **Linked alerts:** after the reported credit, the Hold Review job raises an alert for each transfer from a held account to a loan here (SOP 9.1(v)) or to another savings / current account here.
- **Victim bank, customer protection**: the framework follows the transaction date.
- Before 01.01.2027 - the 2017 framework: zero liability on bank fault or a third-party breach reported within 3 working days, limited liability for 4-7 working days (Rs 5,000 BSBD / 10,000 savings / 25,000 current), shadow reversal within 10 working days, resolution within 90 days.
- From 01.01.2027 - RBI (UCB - Responsible Business Conduct) Third Amendment Directions, 2026 (RBI/2026-27/172, 24.06.2026): zero liability on bank negligence or a third-party breach reported within **5 calendar days**; resolution within **45 calendar days** (60 cross-border); compensation for customer-negligence losses up to Rs 50,000 = 85% of the net loss or Rs 25,000, whichever is less, once in a lifetime, reported to the bank and NCRP / 1930 within 5 calendar days, paid within **5 calendar days** of the application (maker-checker), shared RBI / this bank / beneficiary bank and claimed quarterly (CSV: `compensation-claims.csv`); for frauds up to 31.12.2027.
- A shadow reversal is provisional: it is credited with a lien so the customer cannot use it until zero liability is established ("make final") or it is recovered from restored money.
- GL roles: `I4C_SHADOW_CREDIT`, `I4C_COMPENSATION_RECEIVABLE` (RBI and beneficiary-bank shares), `I4C_COMPENSATION_EXPENSE` (this bank's share).
- **Nodal / pool / escrow accounts** (migration 0615), listed on Risk Registers (SOP 9.1(iii)):
- the **disputed amount is held** like on any other account;
- **no prospective hold** is placed on future credits;
- LEAs normally do not ask for suspension or seizure.
(Corrected 23.10.2026: an earlier build skipped holds on these accounts altogether. That contradicted SOP 9.1(iii).)
- **GRM deadlines** as the GRM manual's flow and SOP 9.1 set them: the bank raises the grievance within **7 days** of the customer approaching it, the IO decides within **15 days** (15 more if investigating), and an IO who does not act in time can be escalated to the District GRO.
### Bank-initiated suspected-mule holds (RBI draft, 11.09.2026)
RBI's draft amendment to the KYC Directions (Annex III, effective 01.04.2027 if finalised; banks may adopt earlier) lets the bank itself hold a suspected money-mule transaction. Risk Registers → *Bank-initiated holds*:
1. Hold a flagged transaction of Rs 1,000 or more at once (its amount), or all debits on the account as a last resort, with the reason. The customer is told on the automatic intimation channels (or the officer records it).
2. The customer has **20 days** to explain; the bank decides within **10 days** of the explanation (**30 days** from the hold without one).
3. Satisfied: lift. Not satisfied: refer to the police through NCRP; the police have **30 days** to instruct, otherwise the hold lifts on day 31.
4. Never more than **60 days** without an LEA instruction. The I4C Hold Review job lifts holds at the end of these periods.
5. Grievances are resolved by the nodal officer within **30 days**.
Not for nodal, pool or escrow accounts.
### Sources and versions this is built on (checked 23.10.2026)
| Source | Version used |
|---|---|
| RBI letter on NCRP-CFCFRMS API integration | CO.DOS.KYC.No.S6675, 05.12.2025 |
| I4C-MHA API Specification (Banks) | 2.0 (no later public version found) |
| MHA SOP on NCRP-CFCFRMS custody, restoration and grievance | 02.01.2026 (I4C CEO letter of 03.07.2026 implements it; no revision found) |
| GRM user manual / MRM user manual | V3 03.06.2026 / V4 30.07.2026 (as supplied) |
| Customer protection in fraudulent EBTs (UCBs) | RBI/2026-27/172, 24.06.2026, from 01.01.2027; 2017 framework before |
| Fraud risk management (UCBs) | RBI/DoS/2026-27/439, 31.07.2026 |
| Money-mule temporary debit holds | RBI draft amendment to KYC Directions, 11.09.2026 (still a draft; final directions to be issued) |
| Supreme Court, digital arrest suo motu | Order of 04.08.2026 (GRM / MRM operationalisation, RBI mule SOP) |
### GRM: grievances against a hold
GRM and MRM are I4C web portals with no API. Officers act there; the CBS keeps the bank's record, the deadlines and the documents, and carries orders out on the account through maker-checker.
1. The customer asks the branch to remove the hold / unfreeze. Open a grievance on the hold (Holds Register → hold → **GRM / MRM**).
@@ -205,7 +279,7 @@ GRM and MRM are I4C web portals with no API. Officers act there; the CBS keeps t
1. **Assess** each complaint (hold → GRM / MRM → *Assess for MRM*): *possible by attribution*, *possible pro-rata* (the calculator splits what is held on an account by disputed amount), or *not possible* (already refunded / no balance / other) with the reason. Enter the same on MRM; the **MRM Balance Report** page gives every held account's balances for MRM's *Update Account Balance* (CSV).
2. When the victim's request comes from the IO, record the MR id, the refund account (IFSC blank = this bank), the IO, and the papers: a **court order**, or the IO's **BNSS 106 notice** with the victim's **indemnity bond**, plus the **FIR** above Rs 50,000. Attach their copies.
3. **Bank action:** *Compliance* checks the papers and raises a RESTORE request; *not complied* and *refer back to LEA* need the bank statement; *refer back to victim bank* and *consensus* are recorded for MRM.
4. A second officer approves the RESTORE on Approvals. In the same transaction the amount is released from hold and leaves the suspect account: straight to the victim's account if it is here (RESTORED at once, transaction id `CBS-<id>`), otherwise to the **restoration pool** (IN_POOL) and on by NEFT / IMPS / RTGS through the fund-transfer module (its limits, time window, screening and approval apply). TRANSFER_PENDING becomes RESTORED when the transfer settles (Hold Review job). A failed or refused outward leaves the money in the pool for **Send again**, possibly to a corrected account; it never goes back to the suspect.
4. A second officer approves the RESTORE on Approvals. In the same transaction the amount is released from hold and leaves the suspect account: straight to the victim's account if it is here (RESTORED at once, transaction id `CBS-<id>`), otherwise book-debited to the **I4C Restoration Payable** GL (IN_POOL) and paid by NEFT / IMPS / RTGS from that GL through the fund-transfer module (its time window and payee screening apply). TRANSFER_PENDING becomes RESTORED when the transfer settles (Hold Review job). A failed or refused outward returns the money to the payable for **Send again**, possibly to a corrected account; it never goes back to the suspect. The bank must credit the victim within **15 days** of the IO's notice (the notice date is recorded with the request), and after the refund the suspect has **15 days** to contest it (recorded on the restoration).
5. Enter amount, transaction id / UTR and time on MRM, then mark **Entered on MRM** to close.
**Victim bank** (our customer lost the money, layer 0): open a victim-bank case with the complaint's ack no., record restored money received (amount, UTR, date, account), or why none is expected (already refunded through bank / police notice / court order, declined, declined to pursue, received through court, not reachable).
@@ -302,7 +376,7 @@ The backend unit tests are under `fineract-provider/src/test/java/org/apache/fin
- customer intimation: check the automatic notice in the hold's intimation history, press **Send now** to resend, or call the customer and record it;
- raise releases on IO / court orders (in full, or the amount the order releases); GRM and MRM orders are raised from their records;
- raise a full debit freeze only on an explicit direction.
- **GRM Grievances / MRM Restorations:** filter *Past deadline only* every morning; carry out allowed orders within 48 hours; send pooled restorations again; mark restorations entered on MRM.
- **GRM Grievances / MRM Restorations:** filter *Past deadline only* every morning; carry out allowed orders within 48 hours; send restorations held in the payable again; mark restorations entered on MRM; record LEA notices 15 days before a hold lapses; review suspected mules; file FMR / STR by their due dates; action loan-repayment alerts (Risk Registers page).
- **Approvals:** a second officer approves or rejects. Approval acts on the account immediately. If the account refuses (closed, hold already released at the counter), the request becomes FAILED with the reason.
- **Responses & API Log:**
- retry FAILED responses;
@@ -330,12 +404,13 @@ The backend unit tests are under `fineract-provider/src/test/java/org/apache/fin
- **Follow-up:** store the UTR/RRN on inward postings.
- **No UPI, ATM, POS or AEPS channels.** The response formats for ATM (5), AEPS (6) and POS (11) are built and tested, but nothing produces them until such a channel exists.
- **Branch cash withdrawals** have no specification type. Above Rs 500 they are reported as type 14 with an empty cheque number and remarks "Cash withdrawal at branch counter (no cheque)". **Confirm with I4C.**
- **Current accounts:** the amount hold works, but the CBS's current-account debit block does not reach the account, so a full debit freeze is offered on savings accounts only.
- **Current-account transfer legs** are not linked transaction-to-transaction, so onward tracing from a current account is limited to counter withdrawals.
- **Payee bank name:** only IFSC prefixes in the seeded list map to an I4C bank code. Others are reported with code 0 and the IFSC in full.
- **Payee bank name:** IFSCs not in the seeded prefixes or the bank's own IFSC map are reported with code 0 and the IFSC in full. Until they are mapped, CFCFRMS may not be able to route the next-layer complaint by code. They are listed on Bank Master.
- **GRM and MRM** are I4C portal modules with no published API. Officers act there and record it here; nothing is sent to the portals automatically.
- **Outward restorations** go only from a savings pool account (the fund-transfer module debits savings accounts only); the pool must be configured, and its client's transfer limits must allow the amounts.
- **Current accounts** cannot be debit-frozen (above); GRM *unfreeze* orders apply to savings accounts only.
- **Outward restorations** need the GLs below mapped; without them only restorations to accounts in this bank can be approved.
- **Digital-channel suspension** is enforced on the CBS's own fund transfers. UPI, ATM, cards and AePS are not run by this CBS; the suspension is listed for the systems that run them.
- **Shadow credits** are booked to savings accounts; working days skip weekends only (bank holidays are not known to the CBS).
- **RBI FMR / FIU-IND STR** are filed on their own portals; the CBS keeps the due dates, references and a CSV of the cases.
---
@@ -902,6 +902,21 @@ public class AccountingProcessorHelper {
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount);
}
/**
* A two-line entry between two GL accounts given by id, for a book movement that is not itself a customer
* transaction: an outward transfer parked in suspense and later settled with the sponsor bank, an I4C restoration
* payable. {@code savingsId} links both lines to the savings account involved, if any. The transaction id should
* be non-numeric (e.g. "FT…-P") so it is stored as given and groups the two lines.
*/
public void createGlToGlJournalEntries(final Long officeId, final String currencyCode, final Long debitGlId, final Long creditGlId,
final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) {
final Office office = this.officeRepository.getReferenceById(officeId);
createDebitJournalEntryForSavings(office, currencyCode, this.glAccountRepository.getReferenceById(debitGlId), savingsId,
transactionId, transactionDate, amount);
createCreditJournalEntryForSavings(office, currencyCode, this.glAccountRepository.getReferenceById(creditGlId), savingsId,
transactionId, transactionDate, amount);
}
/** Dr a current product's Current Control, Cr the GL mapped to {@code financialActivity}. */
public void createCurrentControlToFinancialActivityJournalEntries(final Office office, final String currencyCode,
final Long currentProductId, final int financialActivity, final Long currentAccountId, final String transactionId,
@@ -59,7 +59,7 @@ public class AccountBookDebitService {
/** Debits a savings account; returns the m_savings_account_transaction id. */
public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId) {
final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount);
final BigDecimal balance = checkedSavingsBalance(accountId, amount);
kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
@@ -89,6 +89,48 @@ public class AccountBookDebitService {
return Objects.requireNonNull(kh.getKey()).longValue();
}
/**
* Credits a savings account by book entry (a returned outward transfer, money coming back to the customer). Like
* {@link #debitSavings} it posts no journal entry; the caller posts the one that describes the movement. Returns
* the m_savings_account_transaction id.
*/
public long creditSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", accountId);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", "Savings account " + accountId + " not found.",
accountId);
}
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
: (BigDecimal) rows.get(0).get("account_balance_derived");
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, "
+ "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, "
+ "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo());
ps.setBigDecimal(7, balance.add(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, "
+ "total_deposits_derived = COALESCE(total_deposits_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
return Objects.requireNonNull(kh.getKey()).longValue();
}
/** Debits a current account; returns the m_current_account_transaction id. */
public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
final Long userId, final String note) {
@@ -119,6 +161,47 @@ public class AccountBookDebitService {
return Objects.requireNonNull(kh.getKey()).longValue();
}
/**
* A savings account's balance, after checking it is active, not blocked for debits, and can cover {@code amount}
* from what is withdrawable: the balance less every kind of hold (lien, amount hold such as an I4C cyber-fraud hold,
* clearing hold) and the enforced minimum balance, plus any overdraft limit. The generic check below only took the
* lien, so a book debit (a demand draft, an outward transfer) could take money an amount hold was guarding.
*/
private BigDecimal checkedSavingsBalance(final Long accountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT status_enum, sub_status_enum, COALESCE(account_balance_derived, 0) AS balance,
COALESCE(on_hold_funds_derived, 0) + COALESCE(total_savings_amount_on_hold, 0)
+ COALESCE(total_savings_amount_on_hold_clearing, 0) AS held,
CASE WHEN enforce_min_required_balance THEN COALESCE(min_required_balance, 0) ELSE 0 END AS min_balance,
CASE WHEN allow_overdraft THEN COALESCE(overdraft_limit, 0) ELSE 0 END AS overdraft
FROM m_savings_account WHERE id = ?
""", accountId);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", "Savings account " + accountId + " not found.",
accountId);
}
final Map<String, Object> r = rows.get(0);
if (((Number) r.get("status_enum")).intValue() != 300) {
throw new GeneralPlatformDomainRuleException("error.msg.account.not.active", "Savings account " + accountId + " is not active.",
accountId);
}
final int subStatus = r.get("sub_status_enum") == null ? 0 : ((Number) r.get("sub_status_enum")).intValue();
if (subStatus == 400 || subStatus == 600) {
throw new GeneralPlatformDomainRuleException("error.msg.account.debit.blocked",
"Savings account " + accountId + " is blocked for debits.", accountId);
}
final BigDecimal balance = (BigDecimal) r.get("balance");
final BigDecimal available = balance.subtract((BigDecimal) r.get("held")).subtract((BigDecimal) r.get("min_balance"))
.add((BigDecimal) r.get("overdraft"));
if (available.compareTo(amount) < 0) {
throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance",
"Insufficient available balance in savings account " + accountId + ". Available: " + available + ", Requested: "
+ amount,
accountId);
}
return balance;
}
/** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */
private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
@@ -47,12 +47,15 @@ import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAdminReadService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cBankHoldService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cDocumentService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cDocumentService.StoredFile;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cGrievanceService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cListQuery;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRestorationService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRiskService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
@@ -76,12 +79,17 @@ public class I4cCasesApiResource {
private static final String GRIEVANCE = "MANAGE_I4CGRIEVANCE";
private static final String RESTORATION = "MANAGE_I4CRESTORATION";
private static final String REQUEST_ACTION = "REQUESTACTION_I4CHOLD";
private static final String APPROVE_ACTION = "APPROVEACTION_I4CHOLD";
private static final String RISK = "MANAGE_I4CRISK";
private final PlatformSecurityContext context;
private final I4cAdminReadService readService;
private final I4cGrievanceService grievances;
private final I4cRestorationService restorations;
private final I4cDocumentService documents;
private final I4cRiskService risk;
private final I4cBankHoldService bankHolds;
private final I4cAccountGateway gateway;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── GRM ─────────────────────────────────────────────────────────────────
@@ -336,6 +344,329 @@ public class I4cCasesApiResource {
return csvResponse(csv.toString(), "mrm-balance-report-" + LocalDate.now() + ".csv");
}
@POST
@Path("restorations/{id}/contest")
@Operation(summary = "The suspect contested the refund (SOP: within 15 days of it)")
public String contest(@PathParam("id") final Long id, final String body) {
final AppUser user = user(RESTORATION);
final JsonObject json = parse(body);
restorations.recordContest(id, date(json, "contestedOn"), text(json, "remarks"), user);
return restoration(id);
}
@POST
@Path("restorations/{id}/liability")
@Operation(summary = "Victim bank: category, txnDate, reportedOn, ncrpReportedOn, crossBorder, grossLoss, recoveredAmount;"
+ " RBI 2017 or 2026 framework by the transaction date")
public String liability(@PathParam("id") final Long id, final String body) {
final AppUser user = user(RESTORATION);
restorations.assessLiability(id, fields(body), user);
return restoration(id);
}
@POST
@Path("restorations/{id}/compensation/apply")
@Operation(summary = "Victim bank: the customer applied for the RBI 2026 small-value compensation (paid within 5 calendar days)")
public String applyCompensation(@PathParam("id") final Long id) {
final AppUser user = user(RESTORATION);
restorations.applyCompensation(id, user);
return restoration(id);
}
@POST
@Path("restorations/{id}/compensation/decision")
@Operation(summary = "Victim bank, checker: pay (credit the customer) or reject the compensation")
public String payCompensation(@PathParam("id") final Long id, final String body) {
final AppUser user = user(APPROVE_ACTION);
restorations.payCompensation(id, Boolean.TRUE.equals(bool(parse(body), "approve")), user);
return restoration(id);
}
@POST
@Path("restorations/{id}/shadow-credit/finalise")
@Operation(summary = "Victim bank: zero liability established; the provisional shadow reversal becomes usable")
public String finaliseShadowCredit(@PathParam("id") final Long id) {
final AppUser user = user(APPROVE_ACTION);
restorations.finaliseShadowCredit(id, user);
return restoration(id);
}
@GET
@Path("compensation-claims.csv")
@Produces("text/csv")
@Operation(summary = "Compensations paid in a period (a quarter), for the claim to RBI (Annex II(2)) and the beneficiary banks")
public Response compensationClaims(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
reader();
final LocalDate to = toDate == null || toDate.isBlank() ? LocalDate.now() : parseDate(toDate, "toDate");
final LocalDate from = fromDate == null || fromDate.isBlank() ? to.minusMonths(3) : parseDate(fromDate, "fromDate");
final StringBuilder csv = new StringBuilder("Case,Ack No,Account,Txn Date,Reported to Bank,Reported to NCRP,Gross Loss,Recovered,"
+ "Compensation,RBI Share,Own Share,Beneficiary Bank Share,Cross-border,Paid On\n");
for (final Map<String, Object> r : restorations.compensationClaims(from, to)) {
csv.append(String.join(",", cell(r.get("id")), cell(r.get("acknowledgement_no")), cell(r.get("victim_account_no")),
cell(r.get("txn_date")), cell(r.get("customer_reported_on")), cell(r.get("ncrp_reported_on")), cell(r.get("gross_loss")),
cell(r.get("recovered_amount")), cell(r.get("comp_amount")), cell(r.get("comp_share_rbi")), cell(r.get("comp_share_own")),
cell(r.get("comp_share_beneficiary")), cell(r.get("cross_border")), cell(r.get("comp_paid_on")))).append('\n');
}
return csvResponse(csv.toString(), "fraud-compensation-claims-" + from + "-to-" + to + ".csv");
}
@POST
@Path("restorations/{id}/shadow-credit")
@Operation(summary = "Victim bank, maker: ask for a shadow credit to the customer")
public String requestShadowCredit(@PathParam("id") final Long id, final String body) {
final AppUser user = user(RESTORATION);
restorations.requestShadowCredit(id, decimal(parse(body), "amount"), user);
return restoration(id);
}
@POST
@Path("restorations/{id}/shadow-credit/decision")
@Operation(summary = "Victim bank, checker: approve (credit the customer) or reject the shadow credit")
public String decideShadowCredit(@PathParam("id") final Long id, final String body) {
final AppUser user = user(APPROVE_ACTION);
restorations.approveShadowCredit(id, Boolean.TRUE.equals(bool(parse(body), "approve")), user);
return restoration(id);
}
@POST
@Path("restorations/{id}/regularise")
@Operation(summary = "Victim bank: recover the shadow credit once restored money arrived")
public String regularise(@PathParam("id") final Long id) {
final AppUser user = user(APPROVE_ACTION);
restorations.regularise(id, user);
return restoration(id);
}
// ── Risk: mule register, fraud reporting, linked repayments, 90-day lapse, channel suspension ──
@GET
@Path("mule-flags")
@Operation(summary = "Mule-account register: status, search, fromDate, toDate, offset, limit")
public String muleFlags(@Context final UriInfo uriInfo) {
reader();
return gson.toJson(readService.muleFlags(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@POST
@Path("holds/{holdId}/mule-flag")
public String flagMule(@PathParam("holdId") final Long holdId, final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
return created(risk.flagManually(holdId, text(json, "source"), text(json, "reason"), user));
}
@POST
@Path("mule-flags/{id}/review")
@Operation(summary = "CONFIRMED (a mule) or CLEARED (not a mule), with the basis")
public String reviewMule(@PathParam("id") final Long id, final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
risk.reviewMule(id, text(json, "outcome"), text(json, "remarks"), user);
return created(id);
}
@GET
@Path("fraud-reports")
@Operation(summary = "Fraud reporting register (police, RBI FMR, FIU-IND STR): status, overdue, search, fromDate, toDate, offset, limit")
public String fraudReports(@Context final UriInfo uriInfo) {
reader();
return gson.toJson(readService.fraudReports(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@POST
@Path("holds/{holdId}/fraud-report")
@Operation(summary = "Classify the account as a fraud case today and open its reporting record")
public String openFraudReport(@PathParam("holdId") final Long holdId) {
final AppUser user = user(RISK);
return created(risk.openFraudReport(holdId, user));
}
@POST
@Path("fraud-reports/{id}/{step}")
@Operation(summary = "show-cause {sentOn} | response {respondedOn, response} | classification {fraud, orderRef, orderOn}"
+ " (UCB Fraud Risk Management Directions, 2026: 21 days and a reasoned order before classification)")
public String fraudReportStep(@PathParam("id") final Long id, @PathParam("step") final String step, final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
switch (step) {
case "show-cause" -> risk.recordShowCause(id, date(json, "sentOn"), user);
case "response" -> risk.recordShowCauseResponse(id, date(json, "respondedOn"), text(json, "response"), user);
case "classification" -> risk.decideClassification(id, Boolean.TRUE.equals(bool(json, "fraud")), text(json, "orderRef"),
date(json, "orderOn"), user);
default -> throw new PlatformDataIntegrityException("error.msg.i4c.fraud.step", "Unknown step " + step + ".");
}
return created(id);
}
@PUT
@Path("fraud-reports/{id}")
@Operation(summary = "Record FMR, STR and police references and dates")
public String updateFraudReport(@PathParam("id") final Long id, final String body) {
final AppUser user = user(RISK);
risk.updateFraudReport(id, fields(body), user);
return created(id);
}
@GET
@Path("fraud-reports.csv")
@Produces("text/csv")
public Response fraudReportsCsv() {
reader();
final StringBuilder csv = new StringBuilder("Account Number,Customer,Acknowledgement Numbers,Amount,Classified On,FMR Due,FMR Ref,FMR Filed,"
+ "STR Due,STR Ref,STR Filed,Police Ref,Police Reported,Status,Remarks\n");
final Map<String, Object> page = readService.fraudReports(I4cListQuery.of(Map.of("limit", List.of("500")), 500));
@SuppressWarnings("unchecked")
final List<Map<String, Object>> rows = (List<Map<String, Object>>) page.get("pageItems");
for (final Map<String, Object> r : rows) {
csv.append(String.join(",", cell(r.get("account_no")), cell(r.get("client_name")), cell(r.get("acknowledgement_nos")),
cell(r.get("amount")), cell(r.get("classified_on")), cell(r.get("fmr_due_on")), cell(r.get("fmr_ref")),
cell(r.get("fmr_filed_on")), cell(r.get("str_due_on")), cell(r.get("str_ref")), cell(r.get("str_filed_on")),
cell(r.get("police_ref")), cell(r.get("police_reported_on")), cell(r.get("status")), cell(r.get("remarks")))).append('\n');
}
return csvResponse(csv.toString(), "i4c-fraud-reports-" + LocalDate.now() + ".csv");
}
@GET
@Path("linked-alerts")
@Operation(summary = "Fraud money that repaid loans here: status, search, fromDate, toDate, offset, limit")
public String linkedAlerts(@Context final UriInfo uriInfo) {
reader();
return gson.toJson(readService.linkedAlerts(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@POST
@Path("linked-alerts/{id}")
@Operation(summary = "ACTIONED (amount held on the loan / linked account told) or DISMISSED, with remarks")
public String actionAlert(@PathParam("id") final Long id, final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
risk.actionAlert(id, text(json, "outcome"), text(json, "remarks"), user);
return created(id);
}
@GET
@Path("channel-blocks")
@Operation(summary = "Digital-channel suspensions: status, search, fromDate, toDate, offset, limit")
public String channelBlocks(@Context final UriInfo uriInfo) {
reader();
return gson.toJson(readService.channelBlocks(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@POST
@Path("holds/{holdId}/lea-notice")
@Operation(summary = "SOP 90-day rule: the notice sent to the LEA 15 days before the hold lapses")
public String leaNotice(@PathParam("holdId") final Long holdId, final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
risk.recordLeaNotice(holdId, date(json, "sentOn"), text(json, "reference"), user);
return created(holdId);
}
@POST
@Path("holds/{holdId}/extension")
@Operation(summary = "SOP 90-day rule: the LEA extended the hold (a lawful direction)")
public String extension(@PathParam("holdId") final Long holdId, final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
risk.recordExtension(holdId, date(json, "until"), text(json, "reference"), user);
return created(holdId);
}
// ── IFSC -> I4C Bank Code map (so onward transfers name the next bank) ──
@GET
@Path("ifsc-map")
@Operation(summary = "The bank's IFSC / prefix -> I4C Bank Code map, and reported IFSCs that had no code")
public String ifscMap() {
reader();
return gson.toJson(readService.ifscMap());
}
@POST
@Path("ifsc-map")
public String mapIfsc(final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
final Integer code = json.has("bankCode") && !json.get("bankCode").isJsonNull() ? json.get("bankCode").getAsInt() : null;
return created(risk.mapIfsc(text(json, "ifscKey"), code, text(json, "note"), user));
}
@POST
@Path("ifsc-map/{id}/remove")
public String unmapIfsc(@PathParam("id") final Long id) {
user(RISK);
risk.unmapIfsc(id);
return created(id);
}
// ── Nodal / pool / escrow exclusions ────────────────────────────────────
@GET
@Path("exclusions")
@Operation(summary = "Nodal / pool / escrow accounts not held on I4C complaints: status, search, offset, limit")
public String exclusions(@Context final UriInfo uriInfo) {
reader();
return gson.toJson(readService.exclusions(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@POST
@Path("exclusions")
public String addExclusion(final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
final String accountNo = text(json, "accountNo");
final I4cAccountGateway.AccountRef account = gateway.resolve(accountNo)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.i4c.account.not.found", "Account " + accountNo + " not found."));
return created(risk.addExclusion(account, text(json, "kind"), text(json, "reason"), user));
}
@POST
@Path("exclusions/{id}/remove")
public String removeExclusion(@PathParam("id") final Long id) {
final AppUser user = user(RISK);
risk.removeExclusion(id, user);
return created(id);
}
// ── Bank-initiated debit holds (RBI draft mule SOP, Sep 2026) ───────────
@GET
@Path("bank-holds")
@Operation(summary = "Bank-initiated suspected-mule holds: status (or OPEN), due, search, fromDate, toDate, offset, limit")
public String bankHolds(@Context final UriInfo uriInfo) {
reader();
return gson.toJson(readService.bankHolds(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@POST
@Path("bank-holds")
@Operation(summary = "Hold a suspected mule transaction (Rs 1,000+) at once, or the whole account as a last resort")
public String placeBankHold(final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
return created(bankHolds.place(text(json, "accountNo"), text(json, "scope"), text(json, "trigger"), text(json, "txnRef"),
decimal(json, "amount"), text(json, "reason"), user));
}
@POST
@Path("bank-holds/{id}/{step}")
@Operation(summary = "notice {mode, reference} | explanation {receivedOn, explanation} | decision {decision: LIFT|REFER, remarks, ncrpRef}"
+ " | police {instruction: CONTINUE|LIFT, reference} | grievance {text} | grievance-resolution {resolution}")
public String bankHoldStep(@PathParam("id") final Long id, @PathParam("step") final String step, final String body) {
final AppUser user = user(RISK);
final JsonObject json = parse(body);
switch (step) {
case "notice" -> bankHolds.recordNotice(id, text(json, "mode"), text(json, "reference"), user);
case "explanation" -> bankHolds.recordExplanation(id, date(json, "receivedOn"), text(json, "explanation"), user);
case "decision" -> bankHolds.decide(id, text(json, "decision"), text(json, "remarks"), text(json, "ncrpRef"), user);
case "police" -> bankHolds.recordPolice(id, text(json, "instruction"), text(json, "reference"), user);
case "grievance" -> bankHolds.raiseGrievance(id, text(json, "text"), user);
case "grievance-resolution" -> bankHolds.resolveGrievance(id, text(json, "resolution"), user);
default -> throw new PlatformDataIntegrityException("error.msg.i4c.bankhold.step", "Unknown step " + step + ".");
}
return created(id);
}
// ── Statement and documents ─────────────────────────────────────────────
@GET
@@ -448,6 +779,15 @@ public class I4cCasesApiResource {
return gson.toJson(out);
}
private static Map<String, Object> fields(final String body) {
final Map<String, Object> out = new LinkedHashMap<>();
for (final Map.Entry<String, JsonElement> e : parse(body).entrySet()) {
final JsonElement v = e.getValue();
out.put(e.getKey(), v == null || v.isJsonNull() ? null : v.getAsString());
}
return out;
}
private static JsonObject parse(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
@@ -165,8 +165,6 @@ public class I4cProperties {
@Setter
public static class Restoration {
/** Bank-owned savings account outward restorations leave from; empty = restorations within this bank only. */
private String poolAccountNo;
/** MRM manual: above this amount in a single complaint the IO must give an FIR. */
private java.math.BigDecimal firThreshold = new java.math.BigDecimal("50000");
}
@@ -22,12 +22,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cBankHoldService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cHoldService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cInboundAuthService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cIntimationService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cOutboxService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cProcessingRunner;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRestorationExecutor;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRiskService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
@@ -71,6 +73,8 @@ public class I4cJobsConfig {
private final I4cHoldService holdService;
private final I4cIntimationService intimationService;
private final I4cRestorationExecutor restorationExecutor;
private final I4cRiskService riskService;
private final I4cBankHoldService bankHoldService;
@Bean
public Job i4cDispatchJob() {
@@ -117,6 +121,16 @@ public class I4cJobsConfig {
final int intimated = intimationService.retryPending();
// Outward MRM restorations: settle the ones the fund-transfer module has finished.
final int settled = restorationExecutor.syncOutward();
// SOP: fraud money that repaid a loan here is raised for the officer to hold and tell the linked account.
final int linked = riskService.scanLinkedRepayments();
// RBI draft mule SOP: lift bank holds whose police window (30 days) or 60-day limit ran out.
final int liftedBankHolds = bankHoldService.liftExpired();
if (liftedBankHolds > 0) {
log.info("I4C Hold Review: {} bank-initiated hold(s) lifted at the end of their period", liftedBankHolds);
}
if (linked > 0) {
log.info("I4C Hold Review: {} loan repayment(s) from held accounts raised for action", linked);
}
if (toppedUp + intimated + settled > 0) {
log.info("I4C Hold Review: {} prospective hold(s) topped up, {} customer(s) told automatically, {} restoration(s) settled",
toppedUp, intimated, settled);
@@ -45,9 +45,9 @@ import org.springframework.transaction.annotation.Transactional;
* {@link CurrentAccountTransactionWritePlatformService#holdAmount}), called directly rather than through the command
* queue: the hold must be placed the moment I4C reports the fraud, not after a checker logs in. The hold is the guard
* that every debit path honours - counter and transfer withdrawals, CTS inward cheques and NACH all check the
* withdrawable balance - whereas the savings debit block is skipped by CTS and NACH, and the current-account block does
* not work at all. So the amount hold is the primary control and a debit freeze is only an extra, savings-only step an
* officer can ask for.
* withdrawable balance - whereas the savings debit block is skipped by CTS and NACH. So the amount hold is the primary
* control and a debit freeze is an extra step an officer asks for on an explicit direction. On current accounts the
* freeze sets the BLOCK_DEBIT sub-status, which KycRestrictionGuard enforces on every current-account debit path.
*
* Callers must have a tenant and a signed-in (system) user in context; the write services read both.
*/
@@ -111,6 +111,16 @@ public class I4cAccountGateway {
return Optional.empty();
}
/**
* The kind of a nodal, pool or escrow account the bank listed (MHA SOP 02.01.2026, para 9.1(iii): the disputed amount
* is held, but no prospective hold - 9.1(i) - and normally no suspension or seizure).
*/
public Optional<String> exclusion(final String type, final Long id) {
final List<String> kinds = jdbcTemplate.queryForList(
"SELECT kind FROM m_i4c_hold_exclusion WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'", String.class, type, id);
return kinds.isEmpty() ? Optional.empty() : Optional.of(kinds.get(0));
}
public Optional<AccountRef> byId(final String type, final Long id) {
final String sql = SAVINGS.equals(type) ? "SELECT account_no FROM m_savings_account WHERE id = ?"
: "SELECT account_no FROM m_current_account WHERE id = ?";
@@ -184,14 +194,26 @@ public class I4cAccountGateway {
/** Savings only: the current-account block in this CBS does not reach the account (see class comment). */
public void freezeDebits(final AccountRef account, final String reason) {
requireSavings(account, "Debit freeze");
if (!account.isSavings()) {
final int updated = jdbcTemplate.update(
"UPDATE m_current_account SET sub_status_enum = 600, reason_for_block = ? WHERE id = ? AND COALESCE(sub_status_enum, 0) IN (0, 600)",
truncate(reason, 100), account.id());
if (updated == 0) {
throw new IllegalStateException("Current account " + account.accountNo() + " already carries another block or status");
}
return;
}
final JsonObject json = new JsonObject();
json.addProperty("reasonForBlock", truncate(reason, 100));
savingsWriteService.blockDebits(account.id(), JsonCommand.fromJsonElement(account.id(), json, fromJsonHelper));
}
public void unfreezeDebits(final AccountRef account) {
requireSavings(account, "Debit unfreeze");
if (!account.isSavings()) {
jdbcTemplate.update("UPDATE m_current_account SET sub_status_enum = 0, reason_for_block = NULL WHERE id = ? AND sub_status_enum = 600",
account.id());
return;
}
savingsWriteService.unblockDebits(account.id());
}
@@ -245,12 +267,6 @@ public class I4cAccountGateway {
: new BranchContact((String) rows.get(0).get("name"), (String) rows.get(0).get("contact_phone"));
}
private static void requireSavings(final AccountRef account, final String what) {
if (!account.isSavings()) {
throw new IllegalStateException(
what + " is available only on savings accounts; on a current account the amount hold" + " is the control that works.");
}
}
private static BigDecimal nonNegative(final BigDecimal value) {
return value == null || value.signum() < 0 ? BigDecimal.ZERO : value;
@@ -41,6 +41,7 @@ public class I4cAdminReadService {
private final JdbcTemplate jdbcTemplate;
private final I4cProperties properties;
private final I4cMoneyMover mover;
public Map<String, Object> dashboard() {
final Timestamp dayAgo = Timestamp.from(Instant.now().minusSeconds(86_400));
@@ -75,6 +76,46 @@ public class I4cAdminReadService {
out.put("amountRestored", decimal("SELECT COALESCE(SUM(restored_amount),0) FROM m_i4c_restoration WHERE status IN ('RESTORED','CLOSED')"
+ " AND role = 'BENEFICIARY_BANK'"));
out.put("restorationsMrmToUpdate", count("SELECT COUNT(*) FROM m_i4c_restoration WHERE status = 'RESTORED' AND mrm_updated = false"));
// MHA SOP deadlines and the bank's own risk duties.
out.put("restorationsOverdue", count("""
SELECT COUNT(*) FROM m_i4c_restoration
WHERE status IN ('REQUESTED','AWAITING_APPROVAL','IN_POOL','TRANSFER_PENDING') AND compliance_due_on < CURRENT_DATE
"""));
out.put("restorationsContested", count("SELECT COUNT(*) FROM m_i4c_restoration WHERE contested = true"));
out.put("shadowCreditsDue", count("""
SELECT COUNT(*) FROM m_i4c_restoration
WHERE role = 'VICTIM_BANK' AND status <> 'CLOSED' AND shadow_credit_due_on IS NOT NULL
AND COALESCE(shadow_credit_status, '') <> 'CREDITED' AND liability_category <> 'CUSTOMER_NEGLIGENCE'
"""));
out.put("leaNoticesDue", count("""
SELECT COUNT(*) FROM m_i4c_hold
WHERE status = 'ACTIVE' AND lea_notice_due_on <= CURRENT_DATE AND lea_notice_sent_on IS NULL
AND (extension_until IS NULL OR extension_until < CURRENT_DATE)
"""));
out.put("holdsLapsed", count("""
SELECT COUNT(*) FROM m_i4c_hold
WHERE status = 'ACTIVE' AND lapse_on <= CURRENT_DATE AND (extension_until IS NULL OR extension_until < CURRENT_DATE)
"""));
out.put("digitalSuspended", count("SELECT COUNT(*) FROM m_i4c_channel_block WHERE status = 'ACTIVE'"));
out.put("mulesSuspected", count("SELECT COUNT(*) FROM m_i4c_mule_flag WHERE status = 'SUSPECTED'"));
out.put("mulesConfirmed", count("SELECT COUNT(*) FROM m_i4c_mule_flag WHERE status = 'CONFIRMED'"));
out.put("fraudReportsOverdue", count("""
SELECT COUNT(*) FROM m_i4c_fraud_report
WHERE status IN ('OPEN','SHOW_CAUSE') AND ((fmr_filed_on IS NULL AND fmr_due_on < CURRENT_DATE) OR (str_filed_on IS NULL AND str_due_on < CURRENT_DATE))
"""));
out.put("linkedAlertsOpen", count("SELECT COUNT(*) FROM m_i4c_linked_alert WHERE status = 'OPEN'"));
// Onward transfers reported with no I4C Bank Code: CFCFRMS cannot route these to the next bank by code.
out.put("onwardUnknownBank", count("""
SELECT COUNT(*) FROM m_i4c_finding WHERE txn_type_id = 3 AND entry_json LIKE '%"payee_bank_code":"0"%'
"""));
out.put("bankHoldsOpen", count("SELECT COUNT(*) FROM m_i4c_bank_hold WHERE status IN ('HELD','EXPLAINED','REFERRED','CONTINUED')"));
out.put("bankHoldDecisionsDue", count("""
SELECT COUNT(*) FROM m_i4c_bank_hold WHERE status IN ('HELD','EXPLAINED') AND decision_due_on <= CURRENT_DATE
"""));
out.put("bankHoldGrievancesDue", count("""
SELECT COUNT(*) FROM m_i4c_bank_hold WHERE grievance_raised_on IS NOT NULL AND grievance_resolved_on IS NULL
AND grievance_due_on <= CURRENT_DATE
"""));
// Entries with no txn_type (txn_type_id 0) are the incidents the bank could not act on; name them by why.
out.put("findingsByType", rows("""
SELECT label AS txn_type, COUNT(*) AS count, COALESCE(SUM(amount), 0) AS amount
@@ -262,6 +303,33 @@ public class I4cAdminReadService {
out.put("restorations", rows("SELECT id, mrm_request_no, basis, restorable_amount, victim_name, bank_action, status, utr, restored_amount"
+ " FROM m_i4c_restoration WHERE hold_id = ? ORDER BY id DESC", id));
out.put("documents", documentsOf("HOLD", id));
out.put("linkedAlerts", rows("SELECT * FROM m_i4c_linked_alert WHERE hold_id = ? ORDER BY id DESC", id));
out.put("muleFlags", rows("""
SELECT f.* FROM m_i4c_mule_flag f JOIN m_i4c_hold h ON h.account_type = f.account_type AND h.account_id = f.account_id
WHERE h.id = ? ORDER BY f.id DESC
""", id));
out.put("fraudReports", rows("""
SELECT r.* FROM m_i4c_fraud_report r JOIN m_i4c_hold h ON h.account_no = r.account_no WHERE h.id = ? ORDER BY r.id DESC
""", id));
out.put("channelBlocks", rows("""
SELECT b.* FROM m_i4c_channel_block b JOIN m_i4c_hold h ON h.account_type = b.account_type AND h.account_id = b.account_id
WHERE h.id = ? ORDER BY b.id DESC
""", id));
out.put("reportsOnAccount", count("""
SELECT COUNT(DISTINCT x.acknowledgement_no) FROM m_i4c_hold x JOIN m_i4c_hold h ON h.account_type = x.account_type
AND h.account_id = x.account_id WHERE h.id = ?
""", id));
out.put("canLapseRelease", I4cRiskService.lapsed(found.get(0), DateUtils.getBusinessLocalDate()));
// SOP 9.1(v): the suspect's other savings / current accounts are notified on CFCFRMS.
out.put("relatedAccounts", rows("""
SELECT 'SAVINGS' AS account_type, sa.account_no, sa.status_enum, sa.account_balance_derived AS balance FROM m_savings_account sa
WHERE sa.client_id = ? AND sa.account_no <> ? AND sa.status_enum = 300
UNION ALL
SELECT 'CURRENT', ca.account_no, ca.status_enum, ca.account_balance_derived FROM m_current_account ca
WHERE ca.client_id = ? AND ca.account_no <> ? AND ca.status_enum = 300
""", found.get(0).get("client_id"), found.get(0).get("account_no"), found.get(0).get("client_id"), found.get(0).get("account_no")));
out.put("excludedKind", rows("SELECT kind FROM m_i4c_hold_exclusion WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'",
found.get(0).get("account_type"), found.get(0).get("account_id")));
return out;
}
@@ -284,17 +352,26 @@ public class I4cAdminReadService {
return page(sql.toString(), params, "x.id DESC", q);
}
/** Messages to I4C. Filters: {@code status}, {@code type} (ACK / RESPONSE), {@code search} (ack no., job id) and the date queued. */
/**
* Messages to I4C. Filters: {@code status}, {@code type} (ACK / RESPONSE), {@code layer}, {@code search} (ack no., job id)
* and the date queued.
*/
public Map<String, Object> outbox(final I4cListQuery q) {
final StringBuilder sql = new StringBuilder("""
SELECT * FROM (
SELECT o.id, o.request_id, r.acknowledgement_no, r.job_id, o.message_type, o.status, o.attempts, o.next_attempt_at,
SELECT o.id, o.request_id, r.acknowledgement_no, r.job_id, r.max_layer AS layer, o.message_type, o.status, o.attempts,
o.next_attempt_at,
o.http_status, o.last_error, o.created_at, o.sent_at, o.payload, o.response_body
FROM m_i4c_outbox o JOIN m_i4c_request r ON r.id = o.request_id) x WHERE 1=1
""");
final List<Object> params = new ArrayList<>();
equal(sql, params, "x.status", q.upper("status"));
equal(sql, params, "x.message_type", q.upper("type"));
final Integer outboxLayer = q.integer("layer");
if (outboxLayer != null) {
sql.append(" AND x.layer = ?");
params.add(outboxLayer);
}
like(sql, params, q.text("search"), "x.acknowledgement_no", "x.job_id");
dateRange(sql, params, "x.created_at", q);
return page(sql.toString(), params, "x.id DESC", q);
@@ -302,7 +379,11 @@ public class I4cAdminReadService {
/**
* Every call to and from I4C. Filters: {@code direction}, {@code failures} (HTTP 300+ or no answer), {@code httpStatus},
* {@code responseCode}, {@code search} (endpoint, ack no., caller IP) and the date of the call.
* {@code responseCode}, {@code layer}, {@code search} (endpoint, ack no., caller IP) and the date of the call.
* <p>
* A log row carries the acknowledgement number, which every layer of one complaint shares, so its layer is that of the
* request whose job id the call carried (the ack and response to I4C, and the bank's answer to a push). A token call
* has none.
*/
public Map<String, Object> apiLog(final I4cListQuery q) {
final StringBuilder sql = new StringBuilder("""
@@ -320,9 +401,36 @@ public class I4cAdminReadService {
params.add(httpStatus);
}
equal(sql, params, "x.response_code", q.text("responseCode"));
final Integer layer = q.integer("layer");
if (layer != null) {
sql.append(" AND EXISTS (SELECT 1 FROM m_i4c_request r WHERE r.max_layer = ? AND ").append(CARRIES_JOB_ID).append(')');
params.add(layer);
}
like(sql, params, q.text("search"), "x.endpoint", "x.acknowledgement_no", "x.client_ip");
dateRange(sql, params, "x.created_at", q);
return page(sql.toString(), params, "x.id DESC", q);
final Map<String, Object> out = page(sql.toString(), params, "x.id DESC", q);
addApiLogLayers(out);
return out;
}
private static final String CARRIES_JOB_ID = "r.job_id IS NOT NULL AND (x.request_body LIKE '%' || r.job_id || '%'"
+ " OR x.response_body LIKE '%' || r.job_id || '%')";
/** Sets each listed call's layer from the request whose job id it carried, in one query for the page. */
@SuppressWarnings("unchecked")
private void addApiLogLayers(final Map<String, Object> out) {
final List<Map<String, Object>> items = (List<Map<String, Object>>) out.get("pageItems");
if (items == null || items.isEmpty()) {
return;
}
final List<Object> ids = items.stream().map(i -> i.get("id")).collect(java.util.stream.Collectors.toList());
final String marks = String.join(",", java.util.Collections.nCopies(ids.size(), "?"));
final Map<Object, Object> layers = new java.util.HashMap<>();
for (final Map<String, Object> row : rows("SELECT x.id, MAX(r.max_layer) AS layer FROM m_i4c_api_log x JOIN m_i4c_request r ON "
+ CARRIES_JOB_ID + " WHERE x.id IN (" + marks + ") GROUP BY x.id", ids.toArray())) {
layers.put(row.get("id"), row.get("layer"));
}
items.forEach(i -> i.put("layer", layers.get(i.get("id"))));
}
@@ -424,7 +532,11 @@ public class I4cAdminReadService {
SELECT r.id, r.role, r.hold_id, r.acknowledgement_no, r.mrm_request_no, r.basis, r.restorable_amount, r.victim_name,
r.victim_account_no, r.victim_ifsc, r.bank_action, r.status, r.transfer_channel, r.transfer_status, r.transfer_error, r.utr,
r.restored_amount, r.restored_at, r.mrm_updated, r.received_amount, r.received_utr, r.victim_status, r.created_by, r.created_at,
r.updated_at, h.account_no AS hold_account_no, h.amount_held, c.display_name AS client_name
r.updated_at, r.compliance_due_on, r.challenge_until, r.contested, r.shadow_credit_status, r.shadow_credit_due_on,
r.liability_category,
(r.status IN ('REQUESTED','AWAITING_APPROVAL','IN_POOL','TRANSFER_PENDING') AND r.compliance_due_on < CURRENT_DATE)
AS compliance_overdue,
h.account_no AS hold_account_no, h.amount_held, c.display_name AS client_name
FROM m_i4c_restoration r
LEFT JOIN m_i4c_hold h ON h.id = r.hold_id
LEFT JOIN m_client c ON c.id = h.client_id) x WHERE 1=1
@@ -438,6 +550,12 @@ public class I4cAdminReadService {
equal(sql, params, "x.status", status);
}
equal(sql, params, "x.bank_action", q.upper("bankAction"));
if (q.flag("overdue")) {
sql.append(" AND x.compliance_overdue");
}
if (q.flag("contested")) {
sql.append(" AND x.contested");
}
like(sql, params, q.text("search"), "x.mrm_request_no", "x.acknowledgement_no", "x.victim_name", "x.victim_account_no",
"x.hold_account_no", "x.utr", "x.received_utr");
dateRange(sql, params, "x.created_at", q);
@@ -457,7 +575,7 @@ public class I4cAdminReadService {
final Map<String, Object> out = new LinkedHashMap<>(found.get(0));
out.put("documents", documentsOf("RESTORATION", id));
out.put("firThreshold", properties.getRestoration().getFirThreshold());
out.put("poolConfigured", notBlank(properties.getRestoration().getPoolAccountNo()));
out.put("outwardReady", mover.outwardReady());
if (out.get("action_request_id") != null) {
out.put("actionRequest", rows("SELECT id, action, amount, status, maker_name, checker_name, checker_at, failure_message, rejection_reason"
+ " FROM m_i4c_hold_action_request WHERE id = ?", out.get("action_request_id")));
@@ -465,6 +583,113 @@ public class I4cAdminReadService {
return out;
}
/** Mule-account register. Filters: {@code status}, {@code search} (account, reason) and the date flagged. */
public Map<String, Object> muleFlags(final I4cListQuery q) {
final StringBuilder sql = new StringBuilder("""
SELECT * FROM (SELECT f.*, c.display_name AS client_name FROM m_i4c_mule_flag f LEFT JOIN m_client c ON c.id = f.client_id) x
WHERE 1=1
""");
final List<Object> params = new ArrayList<>();
equal(sql, params, "x.status", q.upper("status"));
like(sql, params, q.text("search"), "x.account_no", "x.client_name", "x.reason");
dateRange(sql, params, "x.flagged_at", q);
return page(sql.toString(), params, "x.id DESC", q);
}
/** Fraud reporting register. Filters: {@code status}, {@code overdue}, {@code search} and the classification date. */
public Map<String, Object> fraudReports(final I4cListQuery q) {
final StringBuilder sql = new StringBuilder("""
SELECT * FROM (
SELECT r.*, c.display_name AS client_name,
(r.status IN ('OPEN','SHOW_CAUSE') AND ((r.fmr_filed_on IS NULL AND r.fmr_due_on < CURRENT_DATE)
OR (r.str_filed_on IS NULL AND r.str_due_on < CURRENT_DATE))) AS overdue
FROM m_i4c_fraud_report r LEFT JOIN m_client c ON c.id = r.client_id) x WHERE 1=1
""");
final List<Object> params = new ArrayList<>();
equal(sql, params, "x.status", q.upper("status"));
if (q.flag("overdue")) {
sql.append(" AND x.overdue");
}
like(sql, params, q.text("search"), "x.account_no", "x.client_name", "x.acknowledgement_nos", "x.fmr_ref", "x.str_ref", "x.police_ref");
dateRange(sql, params, "x.created_at", q);
return page(sql.toString(), params, "x.id DESC", q);
}
/** Fraud money that repaid loans here. Filters: {@code status}, {@code search} and the date raised. */
public Map<String, Object> linkedAlerts(final I4cListQuery q) {
final StringBuilder sql = new StringBuilder("""
SELECT * FROM (SELECT a.*, h.account_no AS hold_account_no, h.acknowledgement_no FROM m_i4c_linked_alert a
JOIN m_i4c_hold h ON h.id = a.hold_id) x WHERE 1=1
""");
final List<Object> params = new ArrayList<>();
equal(sql, params, "x.status", q.upper("status"));
like(sql, params, q.text("search"), "x.hold_account_no", "x.target_no", "x.acknowledgement_no");
dateRange(sql, params, "x.created_at", q);
return page(sql.toString(), params, "x.id DESC", q);
}
/** The bank's IFSC -> I4C Bank Code map, and the IFSCs reported with no code that need mapping. */
public Map<String, Object> ifscMap() {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("mappings", rows("""
SELECT m.*, b.bank_name FROM m_i4c_ifsc_map m LEFT JOIN m_i4c_bank_master b ON b.bank_code = m.bank_code ORDER BY m.ifsc_key
"""));
out.put("unknown", rows("""
SELECT SUBSTRING(entry_json FROM '"ifsc_code":"([A-Z0-9]*)"') AS ifsc, COUNT(*) AS times, MAX(created_at) AS last_seen
FROM m_i4c_finding WHERE txn_type_id = 3 AND entry_json LIKE '%"payee_bank_code":"0"%'
GROUP BY 1 ORDER BY 2 DESC LIMIT 100
"""));
return out;
}
/** Nodal / pool / escrow accounts (prospective hold not applied). Filters: {@code status}, {@code search}. */
public Map<String, Object> exclusions(final I4cListQuery q) {
final StringBuilder sql = new StringBuilder("SELECT * FROM m_i4c_hold_exclusion x WHERE 1=1");
final List<Object> params = new ArrayList<>();
equal(sql, params, "x.status", q.upper("status"));
like(sql, params, q.text("search"), "x.account_no", "x.reason", "x.kind");
dateRange(sql, params, "x.added_at", q);
return page(sql.toString(), params, "x.id DESC", q);
}
/**
* Bank-initiated debit holds (RBI draft mule SOP). Filters: {@code status} (or OPEN), {@code due} (a decision, police
* instruction or grievance past its date), {@code search} and the date placed.
*/
public Map<String, Object> bankHolds(final I4cListQuery q) {
final StringBuilder sql = new StringBuilder("""
SELECT * FROM (
SELECT b.*, c.display_name AS client_name,
((b.status IN ('HELD','EXPLAINED') AND b.decision_due_on <= CURRENT_DATE)
OR (b.status = 'REFERRED' AND b.police_due_on <= CURRENT_DATE)
OR (b.grievance_raised_on IS NOT NULL AND b.grievance_resolved_on IS NULL AND b.grievance_due_on <= CURRENT_DATE)) AS due
FROM m_i4c_bank_hold b LEFT JOIN m_client c ON c.id = b.client_id) x WHERE 1=1
""");
final List<Object> params = new ArrayList<>();
final String status = q.upper("status");
if ("OPEN".equals(status)) {
sql.append(" AND x.status <> 'LIFTED'");
} else {
equal(sql, params, "x.status", status);
}
if (q.flag("due")) {
sql.append(" AND x.due");
}
like(sql, params, q.text("search"), "x.account_no", "x.client_name", "x.flagged_txn_ref", "x.ncrp_ref", "x.reason");
dateRange(sql, params, "x.placed_at", q);
return page(sql.toString(), params, "x.id DESC", q);
}
/** Digital-channel suspensions. Filters: {@code status}, {@code search} and the date placed. */
public Map<String, Object> channelBlocks(final I4cListQuery q) {
final StringBuilder sql = new StringBuilder("SELECT * FROM m_i4c_channel_block x WHERE 1=1");
final List<Object> params = new ArrayList<>();
equal(sql, params, "x.status", q.upper("status"));
like(sql, params, q.text("search"), "x.account_no", "x.reason");
dateRange(sql, params, "x.placed_at", q);
return page(sql.toString(), params, "x.id DESC", q);
}
/** The account's transactions over a period, for the bank statement GRM and MRM ask for. */
public List<Map<String, Object>> statement(final Long holdId, final java.time.LocalDate from, final java.time.LocalDate to) {
final List<Map<String, Object>> hold = rows("SELECT account_type, account_id FROM m_i4c_hold WHERE id = ?", holdId);
@@ -67,6 +67,15 @@ public class I4cBankDirectory {
if (own != null && own > 0) {
return ownBank();
}
// The bank's own map first (a full IFSC, then its prefix), then the master's prefixes.
final List<Map<String, Object>> mapped = jdbcTemplate.queryForList("""
SELECT m.bank_code, COALESCE(b.bank_name, m.note) AS bank_name FROM m_i4c_ifsc_map m
LEFT JOIN m_i4c_bank_master b ON b.bank_code = m.bank_code
WHERE m.ifsc_key IN (?, ?) ORDER BY LENGTH(m.ifsc_key) DESC LIMIT 1
""", code, code.substring(0, 4));
if (!mapped.isEmpty()) {
return new Bank(((Number) mapped.get(0).get("bank_code")).intValue(), (String) mapped.get(0).get("bank_name"));
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT bank_code, bank_name FROM m_i4c_bank_master WHERE ifsc_prefix = ? ORDER BY bank_code LIMIT 1",
code.substring(0, 4));
@@ -0,0 +1,327 @@
/**
* 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.cfcfrms.i4c.service;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Temporary debit holds the bank places itself on suspected money-mule transactions, following RBI's draft amendment
* to the KYC Directions (11.09.2026, Annex III; effective 01.04.2027 if finalised, earlier adoption allowed):
*
* <pre>
* flag (txn of Rs 1,000 or more) -> hold at once (the transaction's amount; the whole account only as a last resort)
* -> tell the customer: reasons, an officer to contact
* customer explains within 20 days -> bank decides within 10 days of the explanation (30 days from the hold without one)
* satisfied -> lift
* not satisfied -> refer to the police through NCRP -> police instruct within 30 days, or the hold lifts on day 31
* never more than 60 days without an LEA / competent authority instruction; grievances resolved within 30 days
* </pre>
*
* The lifts the timeline forces are carried out by the I4C Hold Review job; every other step is an officer's.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class I4cBankHoldService {
public static final BigDecimal MIN_FLAGGED = new BigDecimal("1000");
public static final int EXPLANATION_DAYS = 20;
public static final int DECISION_DAYS_AFTER_EXPLANATION = 10;
public static final int DECISION_DAYS_WITHOUT_EXPLANATION = 30;
public static final int POLICE_DAYS = 30;
public static final int MAX_DAYS = 60;
public static final int GRIEVANCE_DAYS = 30;
public static final Set<String> TRIGGERS = Set.of("PROFILE_MISMATCH", "LINKED_TO_REPORTED_ACCOUNT", "OTHER");
public static final Set<String> SCOPES = Set.of("TRANSACTION", "ACCOUNT");
private final JdbcTemplate jdbcTemplate;
private final I4cAccountGateway gateway;
private final I4cIntimationService intimation;
private final PlatformTransactionManager transactionManager;
/** Places the hold at once and tells the customer on the automatic channels (or leaves it for the officer). */
@Transactional
public Long place(final String accountNo, final String scope, final String trigger, final String txnRef, final BigDecimal flaggedAmount,
final String reason, final AppUser user) {
final AccountRef account = gateway.resolve(accountNo).orElseThrow(() -> error("bankhold.account", "Account " + accountNo + " not found."));
if (account.status() != 300) {
throw error("bankhold.inactive", "Account " + accountNo + " is not active.");
}
if (gateway.exclusion(account.type(), account.id()).isPresent()) {
// The RBI draft excludes nodal, pool and escrow accounts from the mule SOP.
throw error("bankhold.excluded", "Account " + accountNo + " is a nodal / pool / escrow account; the mule hold does not apply to it.");
}
final String s = upper(scope);
final String t = upper(trigger);
if (!SCOPES.contains(s) || !TRIGGERS.contains(t)) {
throw error("bankhold.kind", "Scope must be TRANSACTION or ACCOUNT and the trigger one of " + TRIGGERS + ".");
}
if (flaggedAmount == null || flaggedAmount.compareTo(MIN_FLAGGED) < 0) {
throw error("bankhold.amount", "Only a flagged transaction of Rs 1,000 or more is held (RBI draft).");
}
if (blank(reason) || ("ACCOUNT".equals(s) && reason.trim().length() < 20)) {
throw error("bankhold.reason", "Record the reason; an account-level hold is a last resort and needs the circumstances spelt out.");
}
final Integer open = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_i4c_bank_hold WHERE account_type = ? AND account_id = ? AND status IN ('HELD','EXPLAINED','REFERRED','CONTINUED')",
Integer.class, account.type(), account.id());
if (open != null && open > 0) {
throw error("bankhold.open", "This account already has a bank-initiated hold in force.");
}
Long holdTxnId = null;
BigDecimal held = null;
final String note = "Suspected mule txn " + nz(txnRef) + " (bank hold)";
if ("TRANSACTION".equals(s)) {
held = gateway.withdrawable(account).min(flaggedAmount);
if (held.signum() <= 0) {
throw error("bankhold.nothing", "Nothing is available to hold on this account.");
}
holdTxnId = gateway.placeHold(account, held, note);
} else {
gateway.freezeDebits(account, note);
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final Timestamp now = now();
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_i4c_bank_hold (account_type, account_id, account_no, client_id, office_id, scope, trigger_type, flagged_txn_ref,
flagged_amount, hold_amount, hold_txn_id, reason, status, placed_by, placed_at, explanation_due_on,
decision_due_on, max_until)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'HELD',?,?,?,?,?)
RETURNING id
""", Long.class, account.type(), account.id(), account.accountNo(), account.clientId(), account.officeId(), s, t,
trim(txnRef, 100), flaggedAmount, held, holdTxnId, trim(reason, 1000), name(user), now,
Date.valueOf(today.plusDays(EXPLANATION_DAYS)), Date.valueOf(today.plusDays(DECISION_DAYS_WITHOUT_EXPLANATION)),
Date.valueOf(today.plusDays(MAX_DAYS)));
final I4cIntimationService.Result sent = intimation.notifyCustomer(account.clientId(), "Debit hold on your account " + mask(accountNo),
"Dear customer,\n\nA temporary debit hold" + (held == null ? " on your account " : " of Rs " + held.toPlainString() + " ")
+ mask(accountNo) + " was placed on " + today + " because a transaction (" + nz(txnRef)
+ ") did not match your account profile and needs to be explained.\n\nPlease contact your branch within "
+ EXPLANATION_DAYS + " days with the source of the funds. If no explanation is received, the bank will decide on the hold"
+ " within 30 days. Credits to your account continue normally.\n\nThis is a system-generated message.");
if (sent.outcome() == I4cIntimationService.Outcome.SENT) {
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET customer_notified_at = ?, notification_mode = ?, notification_ref = ? WHERE id = ?", now,
sent.channel(), trim(sent.message(), 200), id);
}
return id;
}
/** The officer told the customer by hand (when no automatic channel reached them). */
@Transactional
public void recordNotice(final Long id, final String mode, final String reference, final AppUser user) {
load(id, "HELD", "EXPLAINED", "REFERRED", "CONTINUED");
if (blank(mode)) {
throw error("bankhold.notice", "Say how the customer was told.");
}
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET customer_notified_at = ?, notification_mode = ?, notification_ref = ? WHERE id = ?", now(),
trim(upper(mode), 20), trim(reference, 200), id);
}
/** The customer's explanation: the decision is due within 10 days of it. */
@Transactional
public void recordExplanation(final Long id, final LocalDate receivedOn, final String explanation, final AppUser user) {
load(id, "HELD");
if (receivedOn == null || blank(explanation)) {
throw error("bankhold.explanation", "Enter the date and the customer's explanation.");
}
jdbcTemplate.update("""
UPDATE m_i4c_bank_hold SET status = 'EXPLAINED', explanation_received_on = ?, explanation = ?, decision_due_on = ? WHERE id = ?
""", Date.valueOf(receivedOn), trim(explanation, 2000), Date.valueOf(receivedOn.plusDays(DECISION_DAYS_AFTER_EXPLANATION)), id);
}
/** LIFT (satisfied) or REFER (to the police through NCRP; police then have 30 days). */
@Transactional
public void decide(final Long id, final String decision, final String remarks, final String ncrpRef, final AppUser user) {
final Map<String, Object> h = load(id, "HELD", "EXPLAINED");
final String d = upper(decision);
if (blank(remarks)) {
throw error("bankhold.remarks", "Record the basis of the decision.");
}
if ("LIFT".equals(d)) {
lift(h, "Bank satisfied: " + remarks.trim(), name(user));
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET decision = 'LIFT', decision_remarks = ?, decided_by = ?, decided_at = ? WHERE id = ?",
trim(remarks, 1000), name(user), now(), id);
return;
}
if (!"REFER".equals(d) || blank(ncrpRef)) {
throw error("bankhold.decision", "Decision is LIFT, or REFER with the NCRP reference of the referral to the police.");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
jdbcTemplate.update("""
UPDATE m_i4c_bank_hold SET status = 'REFERRED', decision = 'REFER', decision_remarks = ?, decided_by = ?, decided_at = ?,
referred_on = ?, ncrp_ref = ?, police_due_on = ?
WHERE id = ?
""", trim(remarks, 1000), name(user), now(), Date.valueOf(today), trim(ncrpRef, 100), Date.valueOf(today.plusDays(POLICE_DAYS)), id);
}
/** The police's instruction: CONTINUE (the hold then follows the LEA, not the 60-day cap) or LIFT. */
@Transactional
public void recordPolice(final Long id, final String instruction, final String reference, final AppUser user) {
final Map<String, Object> h = load(id, "REFERRED");
final String i = upper(instruction);
if (blank(reference) || (!"CONTINUE".equals(i) && !"LIFT".equals(i))) {
throw error("bankhold.police", "Instruction is CONTINUE or LIFT, with the police reference.");
}
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET police_instruction = ?, police_ref = ? WHERE id = ?", i, trim(reference, 200), id);
if ("LIFT".equals(i)) {
lift(h, "Police instructed lifting (" + reference.trim() + ")", name(user));
} else {
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET status = 'CONTINUED' WHERE id = ?", id);
}
}
/** The customer's grievance on the hold; the nodal officer resolves it within 30 days. */
@Transactional
public void raiseGrievance(final Long id, final String text, final AppUser user) {
load(id, "HELD", "EXPLAINED", "REFERRED", "CONTINUED", "LIFTED");
if (blank(text)) {
throw error("bankhold.grievance", "Record the customer's grievance.");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET grievance_raised_on = ?, grievance_due_on = ?, grievance_text = ? WHERE id = ?",
Date.valueOf(today), Date.valueOf(today.plusDays(GRIEVANCE_DAYS)), trim(text, 2000), id);
}
@Transactional
public void resolveGrievance(final Long id, final String resolution, final AppUser user) {
final Map<String, Object> h = load(id, "HELD", "EXPLAINED", "REFERRED", "CONTINUED", "LIFTED");
if (h.get("grievance_raised_on") == null || blank(resolution)) {
throw error("bankhold.grievance.resolve", "Record the resolution of the raised grievance.");
}
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET grievance_resolved_on = ?, grievance_resolution = ? WHERE id = ?",
Date.valueOf(DateUtils.getBusinessLocalDate()), trim(resolution, 2000), id);
}
/**
* Lifts the holds the draft's timeline ends: the police gave no instruction within 30 days of the referral, or 60
* days passed without an LEA instruction. Each in its own transaction. Run by the I4C Hold Review job.
*/
public int liftExpired() {
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT * FROM m_i4c_bank_hold
WHERE (status = 'REFERRED' AND police_due_on < CURRENT_DATE)
OR (status IN ('HELD','EXPLAINED','REFERRED') AND max_until < CURRENT_DATE)
""");
int lifted = 0;
final TransactionTemplate tx = new TransactionTemplate(transactionManager);
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
for (final Map<String, Object> h : due) {
final String why = "REFERRED".equals(h.get("status")) && h.get("police_due_on") != null
? "No police instruction within 30 days of the referral" : "60-day limit reached without an LEA instruction";
try {
tx.executeWithoutResult(status -> lift(h, why, "system"));
lifted++;
} catch (final RuntimeException e) {
log.warn("Bank hold {} could not be lifted automatically: {}", h.get("id"), e.getMessage());
}
}
return lifted;
}
private void lift(final Map<String, Object> h, final String reason, final String actor) {
final AccountRef account = gateway.byId((String) h.get("account_type"), ((Number) h.get("account_id")).longValue())
.orElseThrow(() -> new IllegalStateException("Account " + h.get("account_no") + " no longer exists"));
if ("ACCOUNT".equals(h.get("scope"))) {
// An I4C freeze on the same account (an LEA / court direction) stays.
final Integer i4cFrozen = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_i4c_hold WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE' AND debit_frozen = true",
Integer.class, h.get("account_type"), h.get("account_id"));
if (i4cFrozen == null || i4cFrozen == 0) {
gateway.unfreezeDebits(account);
}
} else if (h.get("hold_txn_id") != null) {
final Long txn = ((Number) h.get("hold_txn_id")).longValue();
if (gateway.isHoldStillOn(account, txn)) {
gateway.releaseHold(account, txn);
}
}
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET status = 'LIFTED', lifted_at = ?, lifted_by = ?, lift_reason = ? WHERE id = ?", now(),
trim(actor, 100), trim(reason, 500), h.get("id"));
}
private Map<String, Object> load(final Long id, final String... allowed) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_bank_hold WHERE id = ? FOR UPDATE", id);
if (rows.isEmpty()) {
throw error("bankhold.not.found", "Bank hold " + id + " not found.");
}
final Map<String, Object> h = rows.get(0);
if (!List.of(allowed).contains(h.get("status"))) {
throw error("bankhold.status", "Not possible while the hold is " + h.get("status") + ".");
}
return h;
}
static String mask(final String accountNo) {
if (accountNo == null || accountNo.length() <= 4) {
return accountNo;
}
return "X".repeat(accountNo.length() - 4) + accountNo.substring(accountNo.length() - 4);
}
private static Timestamp now() {
return Timestamp.from(Instant.now());
}
private static String name(final AppUser user) {
return user == null ? "system" : trim(user.getDisplayName(), 100);
}
private static String nz(final String value) {
return value == null ? "-" : value.trim();
}
private static boolean blank(final String value) {
return value == null || value.isBlank();
}
private static String upper(final String value) {
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
}
private static String trim(final String value, final int max) {
if (value == null || value.isBlank()) {
return null;
}
final String t = value.trim();
return t.length() <= max ? t : t.substring(0, max);
}
private static PlatformDataIntegrityException error(final String code, final String message, final Object... cause) {
return new PlatformDataIntegrityException("error.msg.i4c." + code, message, cause);
}
}
@@ -207,11 +207,15 @@ public class I4cComplaintProcessor {
final String remarks = hold.shortfall().signum() > 0
? "Held Rs " + I4cResponseBuilder.money(hold.amountHeld()) + " of disputed Rs " + I4cResponseBuilder.money(disputed)
+ "; prospective hold on future credits for Rs " + I4cResponseBuilder.money(hold.shortfall())
: "Disputed amount put on hold";
: hold.gap().signum() > 0
? "Held Rs " + I4cResponseBuilder.money(hold.amountHeld()) + " of disputed Rs " + I4cResponseBuilder.money(disputed)
+ "; no prospective hold: " + hold.noProspectiveReason() + " (MHA SOP 9.1)"
: "Disputed amount put on hold";
entries.add(I4cResponseBuilder.putOnHold(root, hold.amountHeld(), now, remarks));
}
if (hold.shortfall().signum() > 0) {
BigDecimal untraced = hold.shortfall();
// What is not held has moved on: report where, so CFCFRMS can notify the next bank in the trail (SOP 6(g), 6(j)).
if (hold.gap().signum() > 0) {
BigDecimal untraced = hold.gap();
final LocalDate from = credit.map(MatchedTxn::date).orElse(date == null ? DateUtils.getBusinessLocalDate() : date);
for (final Movement movement : tracer.debitsAfter(account, credit.map(MatchedTxn::txnId).orElse(null), from, 50)) {
if (untraced.signum() <= 0) {
@@ -221,14 +225,17 @@ public class I4cComplaintProcessor {
untraced = untraced.subtract(movement.amount());
}
if (entries.isEmpty()) {
entries.add(I4cResponseBuilder.putOnHold(root, BigDecimal.ZERO, now, "No balance to hold; prospective hold of Rs "
+ I4cResponseBuilder.money(hold.shortfall()) + " placed on future credits"));
entries.add(I4cResponseBuilder.putOnHold(root, BigDecimal.ZERO, now, hold.shortfall().signum() > 0
? "No balance to hold; prospective hold of Rs " + I4cResponseBuilder.money(hold.shortfall()) + " placed on future credits"
: "No balance to hold; no prospective hold: " + hold.noProspectiveReason() + " (MHA SOP 9.1)"));
}
}
}
saveFindings(requestId, incidentId, entries);
final String outcome = !hold.holdable() ? "NOT_HOLDABLE"
: hold.shortfall().signum() == 0 ? "HELD" : hold.amountHeld().signum() > 0 ? "PARTIALLY_HELD" : "PROSPECTIVE_HOLD";
: hold.gap().signum() == 0 ? "HELD"
: hold.shortfall().signum() > 0 ? (hold.amountHeld().signum() > 0 ? "PARTIALLY_HELD" : "PROSPECTIVE_HOLD")
: hold.amountHeld().signum() > 0 ? "PARTIALLY_HELD" : "NOT_HELD_MOVED_ON";
updateIncident(incidentId, credit.map(MatchedTxn::txnId).orElse(null), credit.map(MatchedTxn::method).orElse("NONE"), outcome,
I4cCodes.STATUS_SUCCESS, credit.isPresent() ? "Credit matched" : "Credit not matched in CBS; held on I4C's report");
}
@@ -0,0 +1,185 @@
/**
* 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.cfcfrms.i4c.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Set;
/**
* Who bears a fraudulent electronic banking transaction reported by our customer, under whichever RBI framework governs
* its date.
*
* <ul>
* <li><b>RBI_2026</b> - (Urban Co-operative Banks - Responsible Business Conduct) Third Amendment Directions, 2026
* (RBI/2026-27/172, 24.06.2026), for transactions on or after 01.01.2027: zero liability on bank negligence, or a
* third-party breach reported within 5 calendar days (later: the bank's policy); customer negligence: the customer bears
* the loss until reporting, with the small-value compensation (para 65.1T); resolution within 45 calendar days, 60 for
* cross-border.</li>
* <li><b>RBI_2017</b> - "Customer Protection - Limiting Liability of Customers in Unauthorised Electronic Banking
* Transactions" (06.07.2017, consolidated in the 2025 directions), for earlier transactions: zero liability on bank
* fault, or a third-party breach reported within 3 working days; limited liability for 4-7 working days; credit
* (shadow reversal) within 10 working days of the report; resolution within 90 days.</li>
* </ul>
*/
public final class I4cCustomerLiability {
public static final LocalDate RBI_2026_FROM = LocalDate.of(2027, 1, 1);
/** Para 65.1U: compensation covers frauds up to one year from the effective date. */
public static final LocalDate COMPENSATION_UNTIL = LocalDate.of(2027, 12, 31);
public static final Set<String> CATEGORIES = Set.of("BANK_NEGLIGENCE", "THIRD_PARTY_BREACH", "CUSTOMER_NEGLIGENCE");
public static final BigDecimal COMPENSATION_MAX_LOSS = new BigDecimal("50000");
public static final BigDecimal COMPENSATION_CAP = new BigDecimal("25000");
public static final BigDecimal COMPENSATION_RATE = new BigDecimal("0.85");
private I4cCustomerLiability() {}
public enum Regime {
RBI_2017, RBI_2026
}
/** The outcome of an assessment: zero liability or not, an explanation, and the due dates. */
public record Assessment(Regime regime, boolean zeroLiability, BigDecimal limitedCap, String note, LocalDate shadowReversalDue,
LocalDate resolutionDue) {
}
/** A compensation decision and how it is shared (para 65.1T). */
public record Compensation(boolean eligible, String reason, BigDecimal amount, BigDecimal rbi, BigDecimal ownBank,
BigDecimal beneficiaryBank) {
}
public static Regime regimeFor(final LocalDate txnDate) {
return txnDate.isBefore(RBI_2026_FROM) ? Regime.RBI_2017 : Regime.RBI_2026;
}
/**
* @param accountKind
* SAVINGS, CURRENT or BSBD, for the 2017 limited-liability cap
*/
public static Assessment assess(final String category, final LocalDate txnDate, final LocalDate reportedOn, final boolean crossBorder,
final String accountKind) {
final Regime regime = regimeFor(txnDate);
if (regime == Regime.RBI_2026) {
final long days = ChronoUnit.DAYS.between(txnDate, reportedOn);
final LocalDate resolution = reportedOn.plusDays(crossBorder ? 60 : 45);
return switch (category) {
case "BANK_NEGLIGENCE" -> new Assessment(regime, true, null, "Bank negligence: zero liability (para 65.1L)", null, resolution);
case "THIRD_PARTY_BREACH" -> days <= 5
? new Assessment(regime, true, null, "Third-party breach reported in " + days + " calendar days: zero liability (65.1M)",
null, resolution)
: new Assessment(regime, false, null,
"Third-party breach reported after 5 calendar days: liability as per the bank's policy (65.1M)", null, resolution);
default -> new Assessment(regime, false, null,
"Customer negligence: the customer bears the loss until reporting, subject to compensation (65.1N, 65.1T)", null,
resolution);
};
}
final int workingDays = workingDaysBetween(txnDate, reportedOn);
final LocalDate shadow = addWorkingDays(reportedOn, 10);
final LocalDate resolution = reportedOn.plusDays(90);
return switch (category) {
case "BANK_NEGLIGENCE" -> new Assessment(regime, true, null, "Bank's fault: zero liability (2017 framework)", shadow, resolution);
case "THIRD_PARTY_BREACH" -> {
if (workingDays <= 3) {
yield new Assessment(regime, true, null, "Third-party breach reported in " + workingDays + " working days: zero liability",
shadow, resolution);
}
if (workingDays <= 7) {
final BigDecimal cap = "BSBD".equals(accountKind) ? new BigDecimal("5000")
: "CURRENT".equals(accountKind) ? new BigDecimal("25000") : new BigDecimal("10000");
yield new Assessment(regime, false, cap, "Reported in " + workingDays
+ " working days: customer liability limited to the transaction value or Rs " + cap.toPlainString(), shadow,
resolution);
}
yield new Assessment(regime, false, null, "Reported after 7 working days: liability as per the bank's policy", shadow,
resolution);
}
default -> new Assessment(regime, false, null, "Customer negligence: the customer bears the loss until reporting", null,
resolution);
};
}
/**
* Para 65.1T: an individual's customer-negligence loss of up to Rs 50,000, reported to the bank and to NCRP / 1930
* within 5 calendar days, compensated once in a lifetime with 85% of the net loss or Rs 25,000, whichever is less.
*/
public static Compensation compensation(final String category, final LocalDate txnDate, final LocalDate reportedToBank,
final LocalDate reportedToNcrp, final BigDecimal grossLoss, final BigDecimal recovered, final boolean crossBorder,
final boolean alreadyCompensated) {
final String why;
if (regimeFor(txnDate) != Regime.RBI_2026) {
why = "The compensation scheme covers frauds from 01.01.2027 only";
} else if (txnDate.isAfter(COMPENSATION_UNTIL)) {
why = "The compensation scheme covers frauds up to one year from 01.01.2027";
} else if (!"CUSTOMER_NEGLIGENCE".equals(category)) {
why = "Compensation is for customer-negligence losses; this case is a reversal";
} else if (grossLoss == null || grossLoss.signum() <= 0 || grossLoss.compareTo(COMPENSATION_MAX_LOSS) > 0) {
why = "The gross loss must be up to Rs 50,000";
} else if (reportedToBank == null || reportedToNcrp == null || ChronoUnit.DAYS.between(txnDate, reportedToBank) > 5
|| ChronoUnit.DAYS.between(txnDate, reportedToNcrp) > 5) {
why = "It must be reported to the bank and to NCRP / 1930 within 5 calendar days";
} else if (alreadyCompensated) {
why = "Compensation is once in a lifetime and this customer has had it";
} else {
why = null;
}
if (why != null) {
return new Compensation(false, why, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
}
final BigDecimal net = grossLoss.subtract(recovered == null ? BigDecimal.ZERO : recovered).max(BigDecimal.ZERO);
final BigDecimal pct = net.multiply(COMPENSATION_RATE).setScale(2, RoundingMode.HALF_UP);
if (pct.compareTo(COMPENSATION_CAP) >= 0) {
// Rs 29,412 and above: a flat Rs 25,000, in the directions' fixed shares.
return crossBorder
? new Compensation(true, "Rs 25,000 (cap)", COMPENSATION_CAP, new BigDecimal("19118"), new BigDecimal("5882"), BigDecimal.ZERO)
: new Compensation(true, "Rs 25,000 (cap)", COMPENSATION_CAP, new BigDecimal("19118"), new BigDecimal("2941"),
new BigDecimal("2941"));
}
// Below Rs 29,412: 85% of the net loss, shared 65 / 10 / 10 (cross-border 65 / 20) per cent of the net loss.
final BigDecimal own = net.multiply(new BigDecimal(crossBorder ? "0.20" : "0.10")).setScale(2, RoundingMode.HALF_UP);
final BigDecimal beneficiary = crossBorder ? BigDecimal.ZERO : net.multiply(new BigDecimal("0.10")).setScale(2, RoundingMode.HALF_UP);
final BigDecimal rbi = pct.subtract(own).subtract(beneficiary);
return new Compensation(true, "85% of the net loss of Rs " + net.toPlainString(), pct, rbi, own, beneficiary);
}
static int workingDaysBetween(final LocalDate from, final LocalDate to) {
int n = 0;
for (LocalDate d = from.plusDays(1); !d.isAfter(to); d = d.plusDays(1)) {
if (d.getDayOfWeek() != DayOfWeek.SATURDAY && d.getDayOfWeek() != DayOfWeek.SUNDAY) {
n++;
}
}
return n;
}
static LocalDate addWorkingDays(final LocalDate from, final int days) {
LocalDate d = from;
int left = days;
while (left > 0) {
d = d.plusDays(1);
if (d.getDayOfWeek() != DayOfWeek.SATURDAY && d.getDayOfWeek() != DayOfWeek.SUNDAY) {
left--;
}
}
return d;
}
}
@@ -39,8 +39,9 @@ import org.springframework.transaction.annotation.Transactional;
* The bank's record of a GRM grievance on an I4C hold, following the GRM user manual:
*
* <pre>
* customer at branch -> EDD -> DRAFT -> (GR id from GRM, KYC / application / photo / statement) -> NODAL_REVIEW
* -> nodal approves -> WITH_IO (7 days) -> [IO investigates: IO_INVESTIGATION, 15 days]
* customer at branch -> EDD -> DRAFT (bank raises it within 7 days) -> (GR id, KYC / application / photo / statement)
* -> NODAL_REVIEW -> nodal approves -> WITH_IO (15 days) -> [IO investigates: IO_INVESTIGATION, 15 days]
* -> IO does not act in time -> escalated to the District GRO (SOP 9.1)
* -> IO decides: allowed -> order carried out on the account through maker-checker -> CLOSED
* rejected -> branch escalates -> WITH_DGRO (15 days) -> rejected -> WITH_SGRO (15 days) -> rejected -> COURT
* </pre>
@@ -58,16 +59,20 @@ public class I4cGrievanceService {
/** GRM manual, branch entry form: KYC document, application, photograph and bank statement of the account holder. */
public static final List<String> REQUIRED_DOCUMENTS = List.of("KYC", "APPLICATION", "PHOTO", "BANK_STATEMENT");
public static final Set<String> DECISIONS = Set.of("REMOVE_HOLD", "UNFREEZE_HOLD_CONTINUES", "UNFREEZE_AND_REMOVE_HOLD", "PARTIAL_HOLD",
"REJECTED_CONTINUE", "SEEK_INFO");
"RESTORE_DIGITAL", "REJECTED_CONTINUE", "SEEK_INFO");
public static final Set<String> NOTE_EVENTS = Set.of("VC_REQUESTED", "VC_DONE", "VC_NOT_REQUIRED", "INFO_SOUGHT", "INFO_SENT", "NOTE");
static final Duration IO_WINDOW = Duration.ofDays(7);
/** GRM manual / SOP 9.1: the bank forwards the grievance within 7 days, after its KYC / CDD / EDD. */
static final Duration BANK_WINDOW = Duration.ofDays(7);
/** SOP 9.1: the IO decides within 15 days; otherwise the grievance escalates to the District GRO. */
static final Duration IO_WINDOW = Duration.ofDays(15);
static final Duration REVIEW_WINDOW = Duration.ofDays(15);
static final Duration COMPLIANCE_WINDOW = Duration.ofHours(48);
private final JdbcTemplate jdbcTemplate;
private final I4cDocumentService documents;
private final I4cHoldService holdService;
private final I4cRiskService riskService;
@Transactional
public Long create(final Long holdId, final String customerRequest, final String customerReason, final AppUser user) {
@@ -86,11 +91,12 @@ public class I4cGrievanceService {
}
final Timestamp now = now();
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_i4c_grievance (hold_id, customer_request, customer_reason, stage, status, stage_entered_at, created_by, created_at,
updated_at)
VALUES (?,?,?,'DRAFT','OPEN',?,?,?,?)
INSERT INTO m_i4c_grievance (hold_id, customer_request, customer_reason, stage, status, stage_entered_at, due_at, created_by,
created_at, updated_at)
VALUES (?,?,?,'DRAFT','OPEN',?,?,?,?,?)
RETURNING id
""", Long.class, holdId, request, trim(customerReason, 2000), now, name(user), now, now);
""", Long.class, holdId, request, trim(customerReason, 2000), now, Timestamp.from(now.toInstant().plus(BANK_WINDOW)), name(user), now,
now);
event(id, "CREATED", "DRAFT", "Customer asked: " + request + (customerReason == null ? "" : " - " + customerReason), user);
return id;
}
@@ -141,6 +147,8 @@ public class I4cGrievanceService {
throw error("grievance.documents", "Attach these documents first (GRM entry form): " + String.join(", ", missing) + ".");
}
move(id, "NODAL_REVIEW", null, "SUBMITTED", "Raised on GRM as " + grievanceNo.trim(), user);
// SOP: without a lawful direction within 90 days of the bank's grievance the hold may be lifted; LEA told 15 days before.
riskService.startLapseClock(((Number) g.get("hold_id")).longValue(), org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate());
jdbcTemplate.update("UPDATE m_i4c_grievance SET grievance_no = ? WHERE id = ?", trim(grievanceNo, 40), id);
}
@@ -261,6 +269,11 @@ public class I4cGrievanceService {
final Long holdId = ((Number) g.get("hold_id")).longValue();
final Map<String, Object> hold = holdService.loadHold(holdId);
final String decision = (String) g.get("decision");
if ("RESTORE_DIGITAL".equals(decision) && !Boolean.TRUE.equals(hold.get("digital_suspended"))) {
jdbcTemplate.update("UPDATE m_i4c_grievance SET compliance_due_at = NULL, updated_at = ? WHERE id = ?", now(), id);
move(id, "CLOSED", null, "CARRIED_OUT", "Digital services were not suspended for this account; no change needed", maker);
return null;
}
if ("UNFREEZE_HOLD_CONTINUES".equals(decision) && !Boolean.TRUE.equals(hold.get("debit_frozen"))) {
// Nothing to change: debits were never frozen for this hold, and the order keeps the hold.
jdbcTemplate.update("UPDATE m_i4c_grievance SET compliance_due_at = NULL, updated_at = ? WHERE id = ?", now(), id);
@@ -273,6 +286,7 @@ public class I4cGrievanceService {
case "REMOVE_HOLD" -> action = I4cHoldService.ACTION_RELEASE;
case "UNFREEZE_AND_REMOVE_HOLD" -> action = I4cHoldService.ACTION_UNFREEZE_AND_RELEASE;
case "UNFREEZE_HOLD_CONTINUES" -> action = I4cHoldService.ACTION_UNFREEZE;
case "RESTORE_DIGITAL" -> action = I4cHoldService.ACTION_RESTORE_DIGITAL;
case "PARTIAL_HOLD" -> {
action = I4cHoldService.ACTION_RELEASE;
amount = ((BigDecimal) hold.get("amount_held")).subtract((BigDecimal) g.get("decision_keep_amount"));
@@ -292,10 +306,14 @@ public class I4cGrievanceService {
@Transactional
public void escalate(final Long id, final String remarks, final AppUser user) {
final Map<String, Object> g = load(id);
if (!"REJECTED".equals(g.get("status")) || "CLOSED".equals(g.get("stage"))) {
throw error("grievance.escalate", "Only a grievance rejected by the IO, DGRO or SGRO can be escalated.");
}
final String stage = (String) g.get("stage");
// SOP 9.1: if the IO does not act within 15 days the grievance goes to the District GRO.
final boolean ioSilent = "OPEN".equals(g.get("status")) && ("WITH_IO".equals(stage) || "IO_INVESTIGATION".equals(stage))
&& g.get("due_at") != null && ((Timestamp) g.get("due_at")).toInstant().isBefore(Instant.now());
if ((!"REJECTED".equals(g.get("status")) && !ioSilent) || "CLOSED".equals(stage)) {
throw error("grievance.escalate",
"Only a grievance rejected by the IO, DGRO or SGRO, or one the IO has not decided within 15 days, can be escalated.");
}
final String next = switch (stage) {
case "WITH_IO", "IO_INVESTIGATION" -> "WITH_DGRO";
case "WITH_DGRO" -> "WITH_SGRO";
@@ -306,7 +324,8 @@ public class I4cGrievanceService {
"CLOSED".equals(next) ? "COURT" : "OPEN", id);
move(id, next, "CLOSED".equals(next) ? null : REVIEW_WINDOW, "CLOSED".equals(next) ? "COURT" : "ESCALATED",
"CLOSED".equals(next) ? "SGRO rejected; the customer may approach the jurisdictional court. " + nz(remarks)
: "Review raised to " + ("WITH_DGRO".equals(next) ? "District" : "State") + " GRO. " + nz(remarks),
: (ioSilent ? "IO did not decide within 15 days; " : "") + "Review raised to "
+ ("WITH_DGRO".equals(next) ? "District" : "State") + " GRO. " + nz(remarks),
user);
}
@@ -71,8 +71,11 @@ public class I4cHoldService {
public static final String ACTION_UNFREEZE_AND_RELEASE = "UNFREEZE_AND_RELEASE";
/** MRM: release the amount and send it to the victim, as one step. */
public static final String ACTION_RESTORE = "RESTORE";
/** SOP: suspend digital banking on a repeatedly reported account, and restore it (GRM "restore digital services"). */
public static final String ACTION_SUSPEND_DIGITAL = "SUSPEND_DIGITAL";
public static final String ACTION_RESTORE_DIGITAL = "RESTORE_DIGITAL";
public static final Set<String> ACTIONS = Set.of(ACTION_RELEASE, ACTION_FREEZE, ACTION_UNFREEZE, ACTION_UNFREEZE_AND_RELEASE,
ACTION_RESTORE);
ACTION_RESTORE, ACTION_SUSPEND_DIGITAL, ACTION_RESTORE_DIGITAL);
public static final Set<String> REASON_TYPES = Set.of("IO_ORDER", "GRM_DECISION", "MRM_RESTORATION", "COURT_ORDER", "REVIEW_90_DAYS",
"LEA_DIRECTION", "WRONG_ACCOUNT", "OTHER");
@@ -86,9 +89,15 @@ public class I4cHoldService {
private final I4cProperties properties;
private final PlatformTransactionManager transactionManager;
private final I4cRestorationExecutor restorationExecutor;
private final I4cRiskService riskService;
/** What was achieved for one incident: amount now held, what is still short, and the register row. */
public record HoldOutcome(Long holdId, BigDecimal amountHeld, BigDecimal shortfall, boolean holdable) {
/**
* What was achieved for one incident: the register row, the amount now held, the prospective hold still to be
* filled from later credits, whether the account could be held at all, and why there is no prospective hold for the
* rest (null when there is one or nothing is missing).
*/
public record HoldOutcome(Long holdId, BigDecimal amountHeld, BigDecimal shortfall, boolean holdable, BigDecimal gap,
String noProspectiveReason) {
}
/**
@@ -97,11 +106,14 @@ public class I4cHoldService {
*/
public HoldOutcome placeForIncident(final Long requestId, final Long incidentId, final String acknowledgementNo, final int layer,
final AccountRef account, final BigDecimal disputedAmount) {
final Optional<Map<String, Object>> existing = findByIncident(incidentId);
final Optional<Map<String, Object>> existing = findByIncident(incidentId).or(() -> findSameIncident(incidentId, acknowledgementNo,
account, layer));
if (existing.isPresent()) {
// A reprocess, or I4C pushing the same reported transaction again in a new request: never a second hold.
final Map<String, Object> h = existing.get();
return new HoldOutcome(((Number) h.get("id")).longValue(), (BigDecimal) h.get("amount_held"), (BigDecimal) h.get("shortfall"),
!NOT_HOLDABLE.equals(h.get("status")));
final BigDecimal held = (BigDecimal) h.get("amount_held");
return new HoldOutcome(((Number) h.get("id")).longValue(), held, (BigDecimal) h.get("shortfall"), !NOT_HOLDABLE.equals(h.get("status")),
((BigDecimal) h.get("disputed_amount")).subtract(held).max(BigDecimal.ZERO), null);
}
final Instant now = Instant.now();
final LocalDate reviewDue = DateUtils.getBusinessLocalDate().plusDays(Math.max(1, properties.getHold().getReviewDays()));
@@ -117,7 +129,14 @@ public class I4cHoldService {
held = toHold;
}
}
final BigDecimal shortfall = account.holdable() ? disputedAmount.subtract(held).max(BigDecimal.ZERO) : disputedAmount;
final BigDecimal gap = disputedAmount.subtract(held).max(BigDecimal.ZERO);
// MHA SOP 9.1(i): the hold on future credits is placed only on first-layer accounts (those that received the money
// straight from the victim's account), and never on nodal, pool or escrow accounts.
final Optional<String> excludedKind = gateway.exclusion(account.type(), account.id());
final String noProspective = gap.signum() == 0 || !account.holdable() ? null
: layer != 1 ? "not a first-layer account (layer " + layer + ")"
: excludedKind.map(k -> k.toLowerCase(java.util.Locale.ROOT) + " account").orElse(null);
final BigDecimal shortfall = account.holdable() && noProspective == null ? gap : BigDecimal.ZERO;
final String status = account.holdable() ? ACTIVE : NOT_HOLDABLE;
final Long holdId = jdbcTemplate.queryForObject(
"""
@@ -135,7 +154,26 @@ public class I4cHoldService {
}
log.info("I4C hold for ack {} layer {} on {} {}: held {} of {}, shortfall {}", acknowledgementNo, layer, account.type(),
account.accountNo(), held, disputedAmount, shortfall);
return new HoldOutcome(holdId, held, shortfall, account.holdable());
if (layer >= 1) {
riskService.flagIfRepeat(account);
}
return new HoldOutcome(holdId, held, shortfall, account.holdable(), account.holdable() ? gap : disputedAmount, noProspective);
}
/**
* The hold already placed for the same reported transaction (same complaint, account, layer, RRN and amount) under
* an earlier request, so a repeated push does not hold the money twice.
*/
private Optional<Map<String, Object>> findSameIncident(final Long incidentId, final String ack, final AccountRef account, final int layer) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT h.* FROM m_i4c_hold h
JOIN m_i4c_incident prev ON prev.id = h.incident_id
JOIN m_i4c_incident cur ON cur.id = ?
WHERE h.acknowledgement_no = ? AND h.account_type = ? AND h.account_id = ? AND h.layer = ?
AND COALESCE(prev.rrn, '') = COALESCE(cur.rrn, '') AND prev.amount = cur.amount AND prev.id <> cur.id
ORDER BY h.id LIMIT 1
""", incidentId, ack, account.type(), account.id(), layer);
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
}
/**
@@ -144,7 +182,7 @@ public class I4cHoldService {
*/
public int topUpProspectiveHolds() {
final List<Map<String, Object>> due = jdbcTemplate
.queryForList("SELECT id FROM m_i4c_hold WHERE status = ? AND shortfall > 0 ORDER BY held_at LIMIT 500", ACTIVE);
.queryForList("SELECT id FROM m_i4c_hold WHERE status = ? AND shortfall > 0 AND layer = 1 ORDER BY held_at LIMIT 500", ACTIVE);
int toppedUp = 0;
final TransactionTemplate tx = newTransaction();
for (final Map<String, Object> row : due) {
@@ -217,21 +255,32 @@ public class I4cHoldService {
if (RELEASED.equals(hold.get("status"))) {
throw error("hold.released", "This hold has already been released.");
}
if ((ACTION_FREEZE.equals(act) || ACTION_SUSPEND_DIGITAL.equals(act)) && (orderReference == null || orderReference.isBlank())) {
// MHA SOP 9.1(i): freezing, suspending digital banking or seizure follow the LEA's lawful direction (S.106 BNSS).
throw error("order.required", "Enter the LEA's direction (order / notice reference) for a freeze or a digital-banking suspension.");
}
if ((ACTION_RELEASE.equals(act) || ACTION_UNFREEZE_AND_RELEASE.equals(act) || ACTION_RESTORE.equals(act))
&& (orderReference == null || orderReference.isBlank()) && !"REVIEW_90_DAYS".equals(reason)) {
// A release takes money out of the fraud trail; it rests on an order, except at the 90-day review.
throw error("order.required", "An order / reference number is required to release a hold.");
}
if ((ACTION_FREEZE.equals(act) || ACTION_UNFREEZE.equals(act)) && !I4cAccountGateway.SAVINGS.equals(hold.get("account_type"))) {
throw error("freeze.savings.only",
"Debit freeze is available only on savings accounts; the amount hold is the control on" + " current accounts.");
}
if (ACTION_FREEZE.equals(act) && Boolean.TRUE.equals(hold.get("debit_frozen"))) {
throw error("already.frozen", "Debits on this account are already frozen for this hold.");
}
if (ACTION_UNFREEZE.equals(act) && !Boolean.TRUE.equals(hold.get("debit_frozen"))) {
throw error("not.frozen", "Debits on this account are not frozen for this hold.");
}
if (ACTION_SUSPEND_DIGITAL.equals(act) && Boolean.TRUE.equals(hold.get("digital_suspended"))) {
throw error("already.suspended", "Digital channels on this account are already suspended.");
}
if (ACTION_RESTORE_DIGITAL.equals(act) && !Boolean.TRUE.equals(hold.get("digital_suspended"))) {
throw error("not.suspended", "Digital channels on this account are not suspended.");
}
if ("REVIEW_90_DAYS".equals(reason) && (ACTION_RELEASE.equals(act) || ACTION_UNFREEZE_AND_RELEASE.equals(act))
&& !I4cRiskService.lapsed(hold, DateUtils.getBusinessLocalDate())) {
// SOP: without a lawful direction the hold may be lifted only once its period ran out with no extension.
throw error("not.lapsed", "The hold has not lapsed yet, or the LEA extended it; a release needs an order.");
}
final BigDecimal held = (BigDecimal) hold.get("amount_held");
if (amount != null) {
if (!ACTION_RELEASE.equals(act) && !ACTION_RESTORE.equals(act)) {
@@ -331,6 +380,7 @@ public class I4cHoldService {
release_reference = ?, released_amount = released_amount + amount_held, amount_held = 0
WHERE id = ?
""", RELEASED, Timestamp.from(now), request.get("reason_type"), trim(reference, 200), holdId);
liftDigitalIfLast(hold);
}
case ACTION_RESTORE -> {
// Release the amount and move it out of the suspect account in the same transaction, so it is never free
@@ -362,6 +412,11 @@ public class I4cHoldService {
gateway.unfreezeDebits(account);
jdbcTemplate.update("UPDATE m_i4c_hold SET debit_frozen = false WHERE id = ?", holdId);
}
case ACTION_SUSPEND_DIGITAL -> riskService.suspendDigital(hold,
"I4C CFCFRMS Ack " + hold.get("acknowledgement_no") + (request.get("order_reference") == null ? ""
: " / " + request.get("order_reference")),
checker.getDisplayName());
case ACTION_RESTORE_DIGITAL -> riskService.restoreDigital(hold, checker.getDisplayName());
default -> throw new IllegalStateException("Unknown action " + action);
}
final int updated = jdbcTemplate.update("""
@@ -405,6 +460,19 @@ public class I4cHoldService {
}
}
/** Digital channels stay suspended while any other active hold remains on the account. */
private void liftDigitalIfLast(final Map<String, Object> hold) {
if (!Boolean.TRUE.equals(hold.get("digital_suspended"))) {
return;
}
final Integer others = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_i4c_hold WHERE account_type = ? AND account_id = ? AND status = ? AND id <> ?", Integer.class,
hold.get("account_type"), hold.get("account_id"), ACTIVE, hold.get("id"));
if (others == null || others == 0) {
riskService.restoreDigital(hold, "system (hold released)");
}
}
private static Long longOrNull(final Object value) {
return value == null ? null : ((Number) value).longValue();
}
@@ -236,6 +236,31 @@ public class I4cIntimationService {
});
}
/**
* Sends a notice to a customer on the switched-on automatic channels, in order, until one reaches them; for notices
* that are not about an I4C hold (a bank-initiated debit hold). Returns the result of the channel that delivered, or
* NOT_DELIVERED / NO_CHANNEL_ENABLED.
*/
public Result notifyCustomer(final Long clientId, final String subject, final String body) {
final List<String> channels = enabledChannels();
if (channels.isEmpty() || clientId == null) {
return new Result(Outcome.NO_CHANNEL_ENABLED, null, null, "No automatic channel is switched on; tell the customer by hand.");
}
String lastReason = "";
for (final String channel : channels) {
try {
final CustomerNotificationResult result = dispatch(channel, clientId, subject, body);
if (result.isDelivered()) {
return new Result(Outcome.SENT, channel, result.destinationMasked(), "Sent to " + result.destinationMasked());
}
lastReason = channel + ": " + (result.failureReason() == null ? result.status().name() : result.failureReason());
} catch (final RuntimeException e) {
lastReason = channel + ": " + e.getMessage();
}
}
return new Result(Outcome.NOT_DELIVERED, null, null, "Could not reach the customer automatically (" + lastReason + ").");
}
/** An officer's own record (phone call, letter, visit), kept in the same log as the automatic notices. */
public void logManual(final Long holdId, final String channel, final String reference, final String actor) {
log(holdId, normalise(channel), "MANUAL", "RECORDED", null, null, trim(reference, 200), null, actor);
@@ -18,60 +18,88 @@
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.account.PortfolioAccountType;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.account.service.AccountBookDebitService;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
import org.apache.fineract.portfolio.clearing.service.ClearingGlAccountResolverService;
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
import org.apache.fineract.portfolio.fundtransfer.data.VerifyRequestData;
import org.apache.fineract.portfolio.fundtransfer.data.VerifyResponseData;
import org.apache.fineract.portfolio.fundtransfer.service.BankIfscLookupService;
import org.apache.fineract.portfolio.fundtransfer.service.FundTransferLedgerService;
import org.apache.fineract.portfolio.fundtransfer.service.FundTransferWritePlatformService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Moves restored money: account to account inside this bank (savings or current, either side), and outward from the
* restoration pool through the CBS fund-transfer module (NEFT / IMPS / RTGS, with that module's own limits, time windows,
* screening and approval). Every call runs as the signed-in officer, in the caller's transaction.
* Moves restored money, with the accounting each movement needs.
*
* <ul>
* <li>Victim's account in this bank: an account-to-account transfer (Dr suspect's control, Cr victim's control).</li>
* <li>Victim's account elsewhere: the suspect is book-debited to the {@code I4C_RESTORATION_PAYABLE} GL (Dr suspect's
* Savings / Current Control, Cr payable), then the fund-transfer module pays the victim's bank from that GL (Dr payable,
* Cr outward suspense; on settlement Dr outward suspense, Cr balance with the sponsor bank). Restored money never sits
* on a customer account, so customer deposits and the payable are both right.</li>
* </ul>
* Every call runs as the signed-in officer, in the caller's transaction.
*/
@Component
@RequiredArgsConstructor
public class I4cMoneyMover {
public static final String RESTORATION_PAYABLE = "I4C_RESTORATION_PAYABLE";
public static final String SHADOW_CREDIT = "I4C_SHADOW_CREDIT";
public static final String COMPENSATION_RECEIVABLE = "I4C_COMPENSATION_RECEIVABLE";
public static final String COMPENSATION_EXPENSE = "I4C_COMPENSATION_EXPENSE";
private final AccountTransfersWritePlatformService transfersService;
private final FundTransferWritePlatformService fundTransferService;
private final FundTransferLedgerService fundTransferLedger;
private final BankIfscLookupService ifscLookup;
private final I4cAccountGateway gateway;
private final I4cProperties properties;
private final ClearingGlAccountResolverService glResolver;
private final AccountingProcessorHelper accounting;
private final AccountBookDebitService bookEntries;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@PersistenceContext
private EntityManager entityManager;
/** Where a fund-transfer row stands: {@code status} as the module records it, and the UTR once there is one. */
public record OutwardState(String status, String utr, String responseMessage) {
}
/** What a book movement produced: the account transaction and the journal entry's transaction id. */
public record Booked(Long accountTxnId, String journalRef) {
}
/** True when the IFSC is blank or belongs to this bank, i.e. the victim's account should be here. */
public boolean isOurs(final String ifsc) {
return ifsc == null || ifsc.isBlank() || ifscLookup.isOurIfsc(ifsc.trim().toUpperCase(java.util.Locale.ROOT));
}
/** The restoration pool account, when configured and usable for an outward transfer. */
public Optional<AccountRef> pool() {
final String number = properties.getRestoration().getPoolAccountNo();
if (number == null || number.isBlank()) {
return Optional.empty();
}
return gateway.resolve(number.trim()).filter(a -> a.isSavings() && a.status() == 300);
/** The restoration payable GL, or null when not mapped. */
public Long payableGl() {
return glResolver.resolveClearingGlAccountId(RESTORATION_PAYABLE);
}
/** True when an outward restoration can be booked: the payable and the outward suspense GLs are mapped. */
public boolean outwardReady() {
return payableGl() != null && fundTransferLedger.glMode();
}
/** Debits {@code from} and credits {@code to} now; returns the account-transfer id. */
@@ -84,20 +112,108 @@ public class I4cMoneyMover {
}
/**
* Sends money from the pool to an account in another bank. The fund-transfer module debits the pool when the
* transfer runs, which is now or when its checker approves it; it returns the module's reference and state.
* Book-debits the suspect account to the restoration payable GL: Dr the account's Savings / Current Control, Cr
* {@code I4C_RESTORATION_PAYABLE}. Pending hold releases are flushed first so the debit sees the freed balance.
*/
public TransferResponseData outwardFromPool(final AccountRef pool, final String channel, final String payeeAccountNo,
final String payeeIfsc, final String payeeName, final BigDecimal amount, final String remarks) {
final VerifyRequestData request = new VerifyRequestData();
request.setChannel(channel);
request.setPayerAccountId(pool.id());
request.setPayeeAccountNumber(payeeAccountNo);
request.setPayeeIfsc(payeeIfsc);
request.setPayeeName(payeeName);
request.setAmount(amount.setScale(2, RoundingMode.DOWN));
final VerifyResponseData verified = fundTransferService.verify(request);
return fundTransferService.createFromToken(verified.getVerificationToken(), truncate(remarks, 200));
public Booked debitToPayable(final AccountRef suspect, final BigDecimal amount, final String reference) {
final Long payable = payableGl();
if (payable == null) {
throw new IllegalStateException("Map the I4C_RESTORATION_PAYABLE GL (Organisation > Clearing GL Configuration) first");
}
final Long control = glResolver.resolveControlGlAccountId(suspect.type(), suspect.id());
if (control == null) {
throw new IllegalStateException("The product of account " + suspect.accountNo() + " has no control GL mapped");
}
entityManager.flush();
final LocalDate date = DateUtils.getBusinessLocalDate();
final BigDecimal value = amount.setScale(2, RoundingMode.DOWN);
final Long userId = context.authenticatedUser().getId();
final long txnId = suspect.isSavings() ? bookEntries.debitSavings(suspect.id(), value, suspect.officeId(), date, userId)
: bookEntries.debitCurrent(suspect.id(), value, suspect.officeId(), date, userId, truncate(reference, 400));
final String ref = truncate(reference, 40) + "-D";
accounting.createGlToGlJournalEntries(suspect.officeId(), currency(suspect), control, payable, suspect.isSavings() ? suspect.id() : null,
ref, date, value);
return new Booked(txnId, ref);
}
/** Pays the victim's bank from the restoration payable GL through the fund-transfer module. */
public TransferResponseData outwardFromPayable(final Long officeId, final String reference, final String channel,
final String payeeAccountNo, final String payeeIfsc, final String payeeName, final BigDecimal amount, final String remarks) {
final Long payable = payableGl();
if (payable == null || !fundTransferLedger.glMode()) {
throw new IllegalStateException("Map I4C_RESTORATION_PAYABLE and FUNDTRANSFER_OUTWARD_SUSPENSE (Clearing GL Configuration) first");
}
return fundTransferService.createFromGl(channel, payable, officeId, reference, payeeAccountNo, payeeIfsc, payeeName,
amount.setScale(2, RoundingMode.DOWN), truncate(remarks, 200));
}
/**
* Victim bank: a shadow credit to our customer from the bank's own funds while the claim is examined (RBI customer
* liability directions). Dr {@code I4C_SHADOW_CREDIT} (receivable), Cr the account's control; book credit.
*/
public Booked shadowCredit(final AccountRef customer, final BigDecimal amount, final String reference) {
final Long receivable = glResolver.resolveClearingGlAccountId(SHADOW_CREDIT);
if (receivable == null) {
throw new IllegalStateException("Map the I4C_SHADOW_CREDIT GL (Organisation > Clearing GL Configuration) first");
}
if (!customer.isSavings()) {
throw new IllegalStateException("A shadow credit is booked to savings accounts; credit a current account through a transfer");
}
final Long control = glResolver.resolveControlGlAccountId(customer.type(), customer.id());
final LocalDate date = DateUtils.getBusinessLocalDate();
final BigDecimal value = amount.setScale(2, RoundingMode.DOWN);
final long txnId = bookEntries.creditSavings(customer.id(), value, customer.officeId(), date, context.authenticatedUser().getId());
final String ref = truncate(reference, 40) + "-SC";
accounting.createGlToGlJournalEntries(customer.officeId(), currency(customer), receivable, control, customer.id(), ref, date, value);
return new Booked(txnId, ref);
}
/**
* Victim bank: restored money arrived for a customer who already had a shadow credit. The shadow credit is
* recovered from the customer's account, clearing the receivable: Dr the account's control, Cr
* {@code I4C_SHADOW_CREDIT}; book debit.
*/
public Booked regulariseShadowCredit(final AccountRef customer, final BigDecimal amount, final String reference) {
final Long receivable = glResolver.resolveClearingGlAccountId(SHADOW_CREDIT);
final Long control = glResolver.resolveControlGlAccountId(customer.type(), customer.id());
if (receivable == null || control == null) {
throw new IllegalStateException("The I4C_SHADOW_CREDIT GL or the account's control GL is not mapped");
}
final LocalDate date = DateUtils.getBusinessLocalDate();
final BigDecimal value = amount.setScale(2, RoundingMode.DOWN);
final long txnId = bookEntries.debitSavings(customer.id(), value, customer.officeId(), date, context.authenticatedUser().getId());
final String ref = truncate(reference, 40) + "-RG";
accounting.createGlToGlJournalEntries(customer.officeId(), currency(customer), control, receivable, customer.id(), ref, date, value);
return new Booked(txnId, ref);
}
/**
* RBI 2026 para 65.1T compensation to our customer: book credit of the full amount; Dr I4C_COMPENSATION_RECEIVABLE
* for the RBI and beneficiary-bank shares and Dr I4C_COMPENSATION_EXPENSE for this bank's share, Cr the control.
*/
public Booked payCompensation(final AccountRef customer, final BigDecimal amount, final BigDecimal recoverable, final BigDecimal ownShare,
final String reference) {
final Long receivable = glResolver.resolveClearingGlAccountId(COMPENSATION_RECEIVABLE);
final Long expense = glResolver.resolveClearingGlAccountId(COMPENSATION_EXPENSE);
final Long control = glResolver.resolveControlGlAccountId(customer.type(), customer.id());
if (receivable == null || expense == null || control == null) {
throw new IllegalStateException("Map I4C_COMPENSATION_RECEIVABLE and I4C_COMPENSATION_EXPENSE (Clearing GL Configuration) first");
}
if (!customer.isSavings()) {
throw new IllegalStateException("Compensation is credited to savings accounts");
}
final LocalDate date = DateUtils.getBusinessLocalDate();
final long txnId = bookEntries.creditSavings(customer.id(), amount.setScale(2, RoundingMode.DOWN), customer.officeId(), date,
context.authenticatedUser().getId());
final String ref = truncate(reference, 40) + "-CP";
if (recoverable.signum() > 0) {
accounting.createGlToGlJournalEntries(customer.officeId(), currency(customer), receivable, control, customer.id(), ref, date,
recoverable);
}
if (ownShare.signum() > 0) {
accounting.createGlToGlJournalEntries(customer.officeId(), currency(customer), expense, control, customer.id(), ref, date, ownShare);
}
return new Booked(txnId, ref);
}
public Optional<OutwardState> outwardState(final String txnRef) {
@@ -110,6 +226,13 @@ public class I4cMoneyMover {
return Optional.of(new OutwardState(String.valueOf(r.get("status")), (String) r.get("utr"), (String) r.get("response_message")));
}
private String currency(final AccountRef account) {
final String sql = account.isSavings() ? "SELECT currency_code FROM m_savings_account WHERE id = ?"
: "SELECT currency_code FROM m_current_account WHERE id = ?";
final List<String> codes = jdbcTemplate.queryForList(sql, String.class, account.id());
return codes.isEmpty() || codes.get(0) == null ? "INR" : codes.get(0);
}
private static PortfolioAccountType typeOf(final AccountRef account) {
return account.isSavings() ? PortfolioAccountType.SAVINGS : PortfolioAccountType.CURRENT;
}
@@ -43,11 +43,13 @@ import org.springframework.transaction.support.TransactionTemplate;
* <ul>
* <li>Victim's account in this bank: account-to-account transfer from the suspect account, in the approval's
* transaction. RESTORED at once; the CBS transfer id is the transaction id entered on MRM.</li>
* <li>Victim's account elsewhere: the money first moves from the suspect account to the restoration pool, in the
* approval's transaction (IN_POOL), then goes out from the pool through the fund-transfer module (TRANSFER_PENDING
* until it settles). A refused or failed outward leaves it in the pool for a retry, possibly to corrected victim
* details (MRM "refer back to victim bank"); it never returns to the suspect.</li>
* <li>Victim's account elsewhere: in the approval's transaction the suspect account is book-debited to the
* {@code I4C_RESTORATION_PAYABLE} GL (status IN_POOL, i.e. held in the payable); then the fund-transfer module pays the
* victim's bank from that GL (TRANSFER_PENDING until it settles, when the outward suspense clears to the sponsor-bank
* settlement account). A refused or failed outward returns the money to the payable for a retry, possibly to
* corrected victim details (MRM "refer back to victim bank"); it never returns to the suspect.</li>
* </ul>
* On RESTORED the suspect's 15-day window to contest the refund (MHA SOP, process 1) starts.
*/
@Slf4j
@Component
@@ -90,25 +92,33 @@ public class I4cRestorationExecutor {
final Long transferId = mover.transfer(suspect, victim, amount, description);
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET status = ?, internal_transfer_id = ?, transfer_channel = 'INTERNAL', transfer_status = 'SUCCESS',
transfer_error = NULL, utr = ?, restored_amount = ?, restored_at = ?, updated_at = ?
transfer_error = NULL, utr = ?, restored_amount = ?, restored_at = ?, challenge_until = ?, updated_at = ?
WHERE id = ?
""", RESTORED, transferId, "CBS-" + transferId, amount, now, now, restorationId);
""", RESTORED, transferId, "CBS-" + transferId, amount, now, challengeUntil(), now, restorationId);
log.info("I4C restoration {}: {} restored from {} to {} in this bank (transfer {})", restorationId, amount, suspect.accountNo(),
victimAccount, transferId);
return;
}
final AccountRef pool = mover.pool().orElseThrow(() -> new IllegalStateException(
"The victim's account is in another bank and no restoration pool account is configured (fineract.i4c.restoration.pool-account-no)"));
final Long poolTransferId = mover.transfer(suspect, pool, amount, description);
if (!mover.outwardReady()) {
throw new IllegalStateException("The victim's account is in another bank: map the I4C_RESTORATION_PAYABLE and"
+ " FUNDTRANSFER_OUTWARD_SUSPENSE GLs (Organisation > Clearing GL Configuration) first");
}
final I4cMoneyMover.Booked booked = mover.debitToPayable(suspect, amount, "I4C-R" + restorationId);
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET status = ?, pool_transfer_id = ?, restored_amount = ?, transfer_error = NULL, updated_at = ?
UPDATE m_i4c_restoration SET status = ?, debit_txn_id = ?, payable_journal_ref = ?, restored_amount = ?, transfer_error = NULL,
updated_at = ?
WHERE id = ?
""", IN_POOL, poolTransferId, amount, now, restorationId);
log.info("I4C restoration {}: {} moved from {} to the restoration pool {} (transfer {})", restorationId, amount, suspect.accountNo(),
pool.accountNo(), poolTransferId);
""", IN_POOL, booked.accountTxnId(), booked.journalRef(), amount, now, restorationId);
log.info("I4C restoration {}: {} debited from {} to the restoration payable (journal {})", restorationId, amount, suspect.accountNo(),
booked.journalRef());
}
/** After the approval committed: send a pooled restoration out. Never throws; a failure is recorded on the row. */
/** The suspect may contest a refund within 15 days of it (MHA SOP, process 1). */
static java.sql.Date challengeUntil() {
return java.sql.Date.valueOf(org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate().plusDays(15));
}
/** After the approval committed: pay out a restoration held in the payable. Never throws; a failure is recorded on the row. */
public void sendOutwardIfPooled(final Long restorationId) {
if (restorationId == null) {
return;
@@ -131,12 +141,11 @@ public class I4cRestorationExecutor {
}
try {
return newTransaction().execute(status -> {
final AccountRef pool = mover.pool()
.orElseThrow(() -> new IllegalStateException("No restoration pool account is configured"));
final String channel = r.get("transfer_channel") == null ? "NEFT" : (String) r.get("transfer_channel");
final TransferResponseData sent = mover.outwardFromPool(pool, channel, (String) r.get("victim_account_no"),
(String) r.get("victim_ifsc"), (String) r.get("victim_name"), (BigDecimal) r.get("restored_amount"),
"I4C MRM restoration Ack " + r.get("acknowledgement_no"));
final Long officeId = jdbcTemplate.queryForObject("SELECT office_id FROM m_i4c_hold WHERE id = ?", Long.class, r.get("hold_id"));
final TransferResponseData sent = mover.outwardFromPayable(officeId, "I4C-R" + restorationId, channel,
(String) r.get("victim_account_no"), (String) r.get("victim_ifsc"), (String) r.get("victim_name"),
(BigDecimal) r.get("restored_amount"), "I4C MRM restoration Ack " + r.get("acknowledgement_no"));
final String state = sent.getStatus() == null ? "" : sent.getStatus().toUpperCase(Locale.ROOT);
final boolean settled = "SUCCESS".equals(state);
final boolean failed = "FAILED".equals(state) || "REVERSED".equals(state);
@@ -144,11 +153,12 @@ public class I4cRestorationExecutor {
final Timestamp now = Timestamp.from(Instant.now());
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET status = ?, fund_transfer_ref = ?, transfer_status = ?, utr = ?, transfer_error = ?,
restored_at = CASE WHEN ? THEN ? ELSE restored_at END, updated_at = ?
restored_at = CASE WHEN ? THEN ? ELSE restored_at END,
challenge_until = CASE WHEN ? THEN ? ELSE challenge_until END, updated_at = ?
WHERE id = ?
""", next, failed ? null : sent.getTxnRef(), state, sent.getUtr(),
failed ? trim("Outward " + channel + " failed: " + sent.getResponseMessage(), 1000) : null, settled, now, now,
restorationId);
failed ? trim("Outward " + channel + " failed: " + sent.getResponseMessage(), 1000) : null, settled, now, settled,
challengeUntil(), now, restorationId);
return next;
});
} catch (final RuntimeException e) {
@@ -161,7 +171,7 @@ public class I4cRestorationExecutor {
/**
* Follows outward restorations to the end: SUCCESS settles them (RESTORED, with the UTR); FAILED / REVERSED put them
* back to IN_POOL, the module having returned the money to the pool. Run by the I4C Hold Review job.
* back to IN_POOL, the module having returned the money to the restoration payable. Run by the I4C Hold Review job.
*/
public int syncOutward() {
int changed = 0;
@@ -176,15 +186,15 @@ public class I4cRestorationExecutor {
final Timestamp now = Timestamp.from(Instant.now());
if ("SUCCESS".equals(s)) {
changed += jdbcTemplate.update("""
UPDATE m_i4c_restoration SET status = ?, transfer_status = ?, utr = ?, restored_at = ?, updated_at = ?
UPDATE m_i4c_restoration SET status = ?, transfer_status = ?, utr = ?, restored_at = ?, challenge_until = ?, updated_at = ?
WHERE id = ? AND status = ?
""", RESTORED, s, state.get().utr(), now, now, row.get("id"), TRANSFER_PENDING);
""", RESTORED, s, state.get().utr(), now, challengeUntil(), now, row.get("id"), TRANSFER_PENDING);
} else if ("FAILED".equals(s) || "REVERSED".equals(s)) {
changed += jdbcTemplate.update("""
UPDATE m_i4c_restoration SET status = ?, transfer_status = ?, fund_transfer_ref = NULL, transfer_error = ?, updated_at = ?
WHERE id = ? AND status = ?
""", IN_POOL, s, trim("Outward transfer " + row.get("fund_transfer_ref") + " " + s + ": " + state.get().responseMessage()
+ "; the money is back in the restoration pool", 1000), now, row.get("id"), TRANSFER_PENDING);
+ "; the money is back in the restoration payable", 1000), now, row.get("id"), TRANSFER_PENDING);
} else {
// Still on its way (awaiting the fund-transfer checker, queued for the NEFT batch, deemed): show where.
jdbcTemplate.update("UPDATE m_i4c_restoration SET transfer_status = ? WHERE id = ? AND transfer_status IS DISTINCT FROM ?", s,
@@ -68,6 +68,10 @@ public class I4cRestorationService {
public static final Set<String> BANK_ACTIONS = Set.of("COMPLIED", "NOT_COMPLIED", "REFER_BACK_LEA", "REFER_BACK_VICTIM_BANK", "CONSENSUS");
public static final Set<String> REFER_BACK_LEA_REASONS = Set.of("ISSUE_COURT_ORDER", "ISSUE_FIR", "ISSUE_ORDER_AND_FIR",
"ISSUE_INDEMNITY_BOND");
/** Who caused the fraudulent transaction; the governing RBI framework follows from its date (see I4cCustomerLiability). */
public static final Set<String> LIABILITY_CATEGORIES = I4cCustomerLiability.CATEGORIES;
/** SOP process 1: the bank credits the victim within 15 days of the IO's notice / order. */
public static final int COMPLIANCE_DAYS = 15;
public static final Set<String> VICTIM_STATUSES = Set.of("ALREADY_REFUNDED_BANK", "ALREADY_REFUNDED_POLICE_NOTICE",
"ALREADY_REFUNDED_COURT_ORDER", "DECLINED_REFUND", "DECLINED_TO_PURSUE", "RECEIVED_THROUGH_COURT", "NOT_REACHABLE");
private static final Pattern IFSC = Pattern.compile("^[A-Z]{4}0[A-Z0-9]{6}$");
@@ -164,6 +168,27 @@ public class I4cRestorationService {
date(f, "courtOrderDate"), trim(text(f, "courtName"), 200), trim(text(f, "bnssNoticeNo"), 100), date(f, "bnssNoticeDate"),
Boolean.TRUE.equals(f.get("indemnityBondReceived")), trim(text(f, "ioName"), 200), trim(text(f, "ioPoliceStation"), 200),
mover.isOurs(ifsc) ? null : channelOr(f), REQUESTED, now(), id);
// SOP: the bank credits the victim within 15 days of receiving the IO's notice / the order.
final Date received = date(f, "noticeReceivedOn") != null ? date(f, "noticeReceivedOn")
: Date.valueOf(org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate());
jdbcTemplate.update("UPDATE m_i4c_restoration SET notice_received_on = ?, compliance_due_on = ? WHERE id = ?", received,
Date.valueOf(received.toLocalDate().plusDays(COMPLIANCE_DAYS)), id);
}
/** The suspect contested the refund (SOP: within 15 days of it); the investigation continues and the bank cooperates. */
@Transactional
public void recordContest(final Long id, final LocalDate contestedOn, final String remarks, final AppUser user) {
final Map<String, Object> r = loadBeneficiary(id);
if (!RESTORED.equals(r.get("status")) && !CLOSED.equals(r.get("status"))) {
throw error("restoration.contest", "Only a restoration already made can be contested.");
}
if (contestedOn == null || blank(remarks)) {
throw error("restoration.contest.details", "Enter the date and the details of the suspect's challenge.");
}
final Object until = r.get("challenge_until");
final boolean late = until != null && contestedOn.isAfter(((Date) until).toLocalDate());
jdbcTemplate.update("UPDATE m_i4c_restoration SET contested = true, contested_on = ?, contest_remarks = ?, updated_at = ? WHERE id = ?",
Date.valueOf(contestedOn), trim((late ? "[after the 15-day window] " : "") + remarks, 1000), now(), id);
}
/**
@@ -260,11 +285,11 @@ public class I4cRestorationService {
return problems;
}
/** An outward restoration stuck in the pool (window closed, switch refused, account corrected): send it again. */
/** An outward restoration held in the payable (window closed, switch refused, account corrected): send it again. */
public String retryOutward(final Long id) {
final Map<String, Object> r = loadBeneficiaryPlain(id);
if (!IN_POOL.equals(r.get("status"))) {
throw error("restoration.not.pooled", "Only a restoration waiting in the pool can be sent again.");
throw error("restoration.not.pooled", "Only a restoration held in the restoration payable can be sent again.");
}
try {
return executor.sendOutward(id);
@@ -318,6 +343,211 @@ public class I4cRestorationService {
now);
}
/**
* The customer's own claim on the bank: when the transaction happened, when it was reported (to the bank and to
* NCRP / 1930), who caused it, and the loss. The RBI framework is chosen by the transaction date (2017 before
* 01.01.2027, the 2026 directions after); the shadow-reversal, resolution and compensation follow from it.
*/
@Transactional
public void assessLiability(final Long id, final Map<String, Object> f, final AppUser user) {
final Map<String, Object> r = loadVictim(id);
final String c = upper(text(f, "category"));
final Date txn = date(f, "txnDate");
final Date reported = date(f, "reportedOn");
if (txn == null || reported == null || !LIABILITY_CATEGORIES.contains(c)) {
throw error("restoration.liability", "Enter the transaction date, the date reported and a category of " + LIABILITY_CATEGORIES + ".");
}
if (reported.before(txn)) {
throw error("restoration.liability.dates", "The report cannot be before the transaction.");
}
final boolean crossBorder = Boolean.TRUE.equals(f.get("crossBorder")) || "true".equalsIgnoreCase(text(f, "crossBorder"));
final Date ncrp = date(f, "ncrpReportedOn");
final BigDecimal gross = decimalOf(f, "grossLoss");
final BigDecimal recovered = decimalOf(f, "recoveredAmount");
final AccountRef account = gateway.resolve((String) r.get("victim_account_no")).orElse(null);
final String kind = account == null ? "SAVINGS" : account.type();
final I4cCustomerLiability.Assessment a = I4cCustomerLiability.assess(c, txn.toLocalDate(), reported.toLocalDate(), crossBorder, kind);
final Long clientId = account == null ? null : account.clientId();
final Integer previous = clientId == null ? 0 : jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_i4c_restoration WHERE victim_client_id = ? AND comp_status = 'PAID' AND id <> ?", Integer.class, clientId,
id);
final I4cCustomerLiability.Compensation comp = I4cCustomerLiability.compensation(c, txn.toLocalDate(), reported.toLocalDate(),
ncrp == null ? null : ncrp.toLocalDate(), gross, recovered, crossBorder, previous != null && previous > 0);
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET txn_date = ?, customer_reported_on = ?, liability_category = ?, liability_regime = ?, liability_note = ?,
customer_liability = ?, shadow_credit_due_on = ?, resolution_due_on = ?, cross_border = ?, ncrp_reported_on = ?, gross_loss = ?,
recovered_amount = ?, victim_client_id = ?, comp_eligible = ?, comp_reason = ?, comp_amount = ?, comp_share_rbi = ?,
comp_share_own = ?, comp_share_beneficiary = ?, updated_at = ?
WHERE id = ?
""", txn, reported, c, a.regime().name(), trim(a.note(), 500), a.limitedCap(),
a.shadowReversalDue() == null ? null : Date.valueOf(a.shadowReversalDue()), Date.valueOf(a.resolutionDue()), crossBorder, ncrp,
gross, recovered, clientId, comp.eligible(), trim(comp.reason(), 500), comp.amount(), comp.rbi(), comp.ownBank(),
comp.beneficiaryBank(), now(), id);
}
/** Para 65.1T(5): the customer applied for compensation; it is paid within 5 calendar days. */
@Transactional
public void applyCompensation(final Long id, final AppUser user) {
final Map<String, Object> r = loadVictim(id);
if (!Boolean.TRUE.equals(r.get("comp_eligible")) || r.get("comp_status") != null) {
throw error("restoration.comp.apply", "This case is not eligible for compensation, or it was already applied for.");
}
final LocalDate today = org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate();
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET comp_status = 'APPLIED', comp_applied_on = ?, comp_due_on = ?, comp_applied_by_id = ?, updated_at = ?
WHERE id = ?
""", Date.valueOf(today), Date.valueOf(today.plusDays(5)), user.getId(), now(), id);
}
/**
* Checker: pays the compensation to the customer. Dr I4C_COMPENSATION_RECEIVABLE (the RBI and beneficiary-bank
* shares, claimed quarterly) and Dr I4C_COMPENSATION_EXPENSE (this bank's share), Cr the account's control.
*/
@Transactional
public void payCompensation(final Long id, final boolean approve, final AppUser checker) {
final Map<String, Object> r = loadVictim(id);
if (!"APPLIED".equals(r.get("comp_status"))) {
throw error("restoration.comp.pay", "No compensation application is waiting.");
}
if (r.get("comp_applied_by_id") != null && checker.getId().equals(((Number) r.get("comp_applied_by_id")).longValue())) {
throw error("restoration.comp.same.user", "Maker-checker: the officer who recorded the application cannot pay it.");
}
if (!approve) {
jdbcTemplate.update("UPDATE m_i4c_restoration SET comp_status = 'REJECTED', updated_at = ? WHERE id = ?", now(), id);
return;
}
final AccountRef customer = gateway.resolve((String) r.get("victim_account_no"))
.orElseThrow(() -> error("restoration.victim.account", "Our customer's account " + r.get("victim_account_no") + " was not found."));
final BigDecimal recoverable = ((BigDecimal) r.get("comp_share_rbi")).add((BigDecimal) r.get("comp_share_beneficiary"));
final I4cMoneyMover.Booked booked = mover.payCompensation(customer, (BigDecimal) r.get("comp_amount"), recoverable,
(BigDecimal) r.get("comp_share_own"), "I4C-C" + id);
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET comp_status = 'PAID', comp_paid_on = ?, comp_txn_id = ?, comp_journal_ref = ?, updated_at = ? WHERE id = ?
""", Date.valueOf(org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate()), booked.accountTxnId(),
booked.journalRef(), now(), id);
}
/** Para 65.1T(6): the quarter's paid compensations, for the claim to RBI (Annex II(2)) and the beneficiary banks. */
public List<Map<String, Object>> compensationClaims(final LocalDate from, final LocalDate to) {
return jdbcTemplate.queryForList("""
SELECT r.id, r.acknowledgement_no, r.victim_account_no, r.txn_date, r.customer_reported_on, r.ncrp_reported_on, r.gross_loss,
r.recovered_amount, r.comp_amount, r.comp_share_rbi, r.comp_share_own, r.comp_share_beneficiary, r.cross_border, r.comp_paid_on
FROM m_i4c_restoration r WHERE r.comp_status = 'PAID' AND r.comp_paid_on BETWEEN ? AND ? ORDER BY r.comp_paid_on, r.id
""", Date.valueOf(from), Date.valueOf(to));
}
/** The shadow reversal becomes final (zero liability established): the lien on the credited amount is lifted. */
@Transactional
public void finaliseShadowCredit(final Long id, final AppUser user) {
final Map<String, Object> r = loadVictim(id);
if (!"CREDITED".equals(r.get("shadow_credit_status")) || r.get("shadow_finalised_at") != null) {
throw error("restoration.shadow.final", "There is no provisional shadow reversal to make final.");
}
releaseShadowLien(r);
jdbcTemplate.update("UPDATE m_i4c_restoration SET shadow_finalised_at = ?, updated_at = ? WHERE id = ?", now(), now(), id);
}
private void releaseShadowLien(final Map<String, Object> r) {
if (r.get("shadow_hold_txn_id") == null) {
return;
}
final AccountRef customer = gateway.resolve((String) r.get("victim_account_no"))
.orElseThrow(() -> error("restoration.victim.account", "Our customer's account was not found."));
final Long holdTxn = ((Number) r.get("shadow_hold_txn_id")).longValue();
if (gateway.isHoldStillOn(customer, holdTxn)) {
gateway.releaseHold(customer, holdTxn);
}
}
private static BigDecimal decimalOf(final Map<String, Object> f, final String key) {
final String v = text(f, key);
if (blank(v)) {
return null;
}
try {
return new BigDecimal(v.trim());
} catch (final NumberFormatException e) {
throw error("restoration.number", key + " must be a number.", e);
}
}
/** Maker: asks for a shadow credit to the customer while the claim is examined. A different officer approves it. */
@Transactional
public void requestShadowCredit(final Long id, final BigDecimal amount, final AppUser maker) {
final Map<String, Object> r = loadVictim(id);
if (r.get("liability_category") == null || "CUSTOMER_NEGLIGENCE".equals(r.get("liability_category"))) {
throw error("restoration.shadow.category",
"A shadow reversal follows a liability assessment that is not customer negligence (that case may get compensation).");
}
if ("CREDITED".equals(r.get("shadow_credit_status")) || "PENDING".equals(r.get("shadow_credit_status"))) {
throw error("restoration.shadow.exists", "A shadow credit is already " + r.get("shadow_credit_status") + ".");
}
if (amount == null || amount.signum() <= 0) {
throw error("restoration.shadow.amount", "Enter the amount to credit.");
}
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET shadow_credit_amount = ?, shadow_credit_status = 'PENDING', shadow_credit_requested_by = ?,
shadow_credit_requested_by_id = ?, updated_at = ?
WHERE id = ?
""", amount, name(maker), maker.getId(), now(), id);
}
/** Checker: credits the customer from the bank's shadow-credit receivable (Dr I4C_SHADOW_CREDIT, Cr Savings Control). */
@Transactional
public void approveShadowCredit(final Long id, final boolean approve, final AppUser checker) {
final Map<String, Object> r = loadVictim(id);
if (!"PENDING".equals(r.get("shadow_credit_status"))) {
throw error("restoration.shadow.pending", "No shadow credit is waiting for approval.");
}
if (checker.getId().equals(r.get("shadow_credit_requested_by_id") == null ? null
: ((Number) r.get("shadow_credit_requested_by_id")).longValue())) {
throw error("restoration.shadow.same.user", "Maker-checker: you cannot approve a shadow credit you requested.");
}
if (!approve) {
jdbcTemplate.update("UPDATE m_i4c_restoration SET shadow_credit_status = 'REJECTED', updated_at = ? WHERE id = ?", now(), id);
return;
}
final AccountRef customer = gateway.resolve((String) r.get("victim_account_no"))
.orElseThrow(() -> error("restoration.victim.account", "Our customer's account " + r.get("victim_account_no") + " was not found."));
final I4cMoneyMover.Booked booked = mover.shadowCredit(customer, (BigDecimal) r.get("shadow_credit_amount"), "I4C-V" + id);
// A shadow reversal is provisional: the customer cannot use it until the case is decided (RBI 2026, 4(24A)).
final Long lien = gateway.placeHold(customer, (BigDecimal) r.get("shadow_credit_amount"), "Shadow reversal I4C-V" + id + " (provisional)");
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET shadow_credit_status = 'CREDITED', shadow_credit_txn_id = ?, shadow_credit_journal_ref = ?,
shadow_credited_by = ?, shadow_credited_at = ?, shadow_hold_txn_id = ?, updated_at = ?
WHERE id = ?
""", booked.accountTxnId(), booked.journalRef(), name(checker), now(), lien, now(), id);
}
/**
* Restored money arrived for a customer who had a shadow credit: the shadow credit is recovered from the account
* (Dr Savings Control, Cr I4C_SHADOW_CREDIT), up to what was received, so the customer is not paid twice.
*/
@Transactional
public void regularise(final Long id, final AppUser user) {
final Map<String, Object> r = loadVictim(id);
if (!"CREDITED".equals(r.get("shadow_credit_status")) || r.get("regularised_at") != null) {
throw error("restoration.regularise", "There is no shadow credit to regularise.");
}
final BigDecimal received = r.get("received_amount") == null ? BigDecimal.ZERO : (BigDecimal) r.get("received_amount");
if (received.signum() <= 0) {
throw error("restoration.regularise.received", "Record the restored money received first.");
}
final BigDecimal amount = received.min((BigDecimal) r.get("shadow_credit_amount"));
final AccountRef customer = gateway.resolve((String) r.get("victim_account_no"))
.orElseThrow(() -> error("restoration.victim.account", "Our customer's account was not found."));
releaseShadowLien(r);
final I4cMoneyMover.Booked booked = mover.regulariseShadowCredit(customer, amount, "I4C-V" + id);
jdbcTemplate.update("""
UPDATE m_i4c_restoration SET regularised_amount = ?, regularise_journal_ref = ?, regularised_at = ?, updated_at = ? WHERE id = ?
""", amount, booked.journalRef(), now(), now(), id);
}
/** {@code days} working days after {@code from}, skipping Saturdays and Sundays (bank holidays are not known here). */
static LocalDate addWorkingDays(final LocalDate from, final int days) {
return I4cCustomerLiability.addWorkingDays(from, days);
}
/** Restored money credited to our customer: amount, UTR, date and the account it reached. */
@Transactional
public void recordReceipt(final Long id, final BigDecimal amount, final String utr, final LocalDate receivedOn, final String accountNo,
@@ -0,0 +1,539 @@
/**
* 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.cfcfrms.i4c.service;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* What the MHA NCRP-CFCFRMS SOP (02.01.2026) and RBI's fraud and AML rules ask of the bank around a suspect account,
* beyond the hold itself.
*
* <ul>
* <li><b>Digital channels</b> suspended on a repeatedly reported account (approved through maker-checker on the hold).
* The CBS's own fund transfers refuse a blocked account; channels the CBS does not run (UPI, ATM, cards, AePS) are
* recorded for the systems that do.</li>
* <li><b>Mule-account register</b>: an account reported in two or more complaints is flagged SUSPECTED automatically;
* an officer confirms or clears it.</li>
* <li><b>Fraud reporting</b>: police / LEA, RBI fraud monitoring return (default 14 days from classification) and the
* FIU-IND suspicious transaction report (default 7 days), with due dates.</li>
* <li><b>Linked repayments</b>: fraud money that repaid a loan here after the reported credit, for the officer to hold
* and tell the linked account, as the SOP asks.</li>
* <li><b>90-day lapse</b>: counted from the bank's grievance; the LEA is told 15 days before, and an extension recorded.</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class I4cRiskService {
/** SOP: digital banking that may be suspended on a repeatedly reported account. */
public static final String ALL_DIGITAL = "INTERNAL,IMPS,NEFT,RTGS,UPI,AEPS,ATM,CARD,NETBANKING";
public static final int REPEAT_THRESHOLD = 2;
public static final int FMR_DAYS = 14;
public static final int STR_DAYS = 7;
/** UCB Fraud Risk Management Directions, 2026: at least 21 days to answer the show-cause notice. */
public static final int SHOW_CAUSE_DAYS = 21;
public static final int LAPSE_DAYS = 90;
public static final int LEA_NOTICE_DAYS_BEFORE = 15;
public static final Set<String> MULE_REVIEW = Set.of("CONFIRMED", "CLEARED");
public static final Set<String> ALERT_OUTCOMES = Set.of("ACTIONED", "DISMISSED");
private final JdbcTemplate jdbcTemplate;
// ── Digital channel suspension (carried out by an approved hold action) ─────────────────────
/** In the approval's transaction: suspends every digital channel on the hold's account. */
public void suspendDigital(final Map<String, Object> hold, final String reason, final String actor) {
final Integer active = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_i4c_channel_block WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'", Integer.class,
hold.get("account_type"), hold.get("account_id"));
if (active == null || active == 0) {
jdbcTemplate.update("""
INSERT INTO m_i4c_channel_block (hold_id, account_type, account_id, account_no, channels, reason, status, placed_by, placed_at)
VALUES (?,?,?,?,?,?,'ACTIVE',?,?)
""", hold.get("id"), hold.get("account_type"), hold.get("account_id"), hold.get("account_no"), ALL_DIGITAL, trim(reason, 500),
trim(actor, 100), now());
}
jdbcTemplate.update("UPDATE m_i4c_hold SET digital_suspended = true WHERE id = ?", hold.get("id"));
}
/** In the approval's transaction: lifts the suspension (GRM "restore digital services", or the hold released). */
public void restoreDigital(final Map<String, Object> hold, final String actor) {
jdbcTemplate.update("""
UPDATE m_i4c_channel_block SET status = 'LIFTED', lifted_by = ?, lifted_at = ?
WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'
""", trim(actor, 100), now(), hold.get("account_type"), hold.get("account_id"));
jdbcTemplate.update("UPDATE m_i4c_hold SET digital_suspended = false WHERE account_type = ? AND account_id = ?", hold.get("account_type"),
hold.get("account_id"));
}
// ── Mule-account register ───────────────────────────────────────────────────────────────
/** After a hold is placed: an account now reported in {@value #REPEAT_THRESHOLD}+ complaints is flagged SUSPECTED. */
public void flagIfRepeat(final AccountRef account) {
final Integer reports = jdbcTemplate.queryForObject(
"SELECT COUNT(DISTINCT acknowledgement_no) FROM m_i4c_hold WHERE account_type = ? AND account_id = ?", Integer.class,
account.type(), account.id());
if (reports == null || reports < REPEAT_THRESHOLD) {
return;
}
final int updated = jdbcTemplate.update(
"UPDATE m_i4c_mule_flag SET reports_count = ? WHERE account_type = ? AND account_id = ? AND status <> 'CLEARED'", reports,
account.type(), account.id());
if (updated == 0) {
jdbcTemplate.update("""
INSERT INTO m_i4c_mule_flag (client_id, account_type, account_id, account_no, source, reports_count, reason, status,
flagged_by, flagged_at)
VALUES (?,?,?,?,'AUTO_REPEAT',?,?,'SUSPECTED','system',?)
""", account.clientId(), account.type(), account.id(), account.accountNo(), reports,
"Reported in " + reports + " I4C complaints", now());
log.info("I4C mule register: {} {} flagged SUSPECTED ({} complaints)", account.type(), account.accountNo(), reports);
}
}
@Transactional
public Long flagManually(final Long holdId, final String source, final String reason, final AppUser user) {
final Map<String, Object> h = hold(holdId);
final String src = upper(source).isEmpty() ? "OFFICER" : upper(source);
if (!Set.of("OFFICER", "LEA").contains(src)) {
throw error("mule.source", "Source must be OFFICER or LEA.");
}
if (blank(reason)) {
throw error("mule.reason", "Give the reason for flagging the account.");
}
final List<Long> existing = jdbcTemplate.queryForList(
"SELECT id FROM m_i4c_mule_flag WHERE account_type = ? AND account_id = ? AND status <> 'CLEARED'", Long.class,
h.get("account_type"), h.get("account_id"));
if (!existing.isEmpty()) {
return existing.get(0);
}
return jdbcTemplate.queryForObject("""
INSERT INTO m_i4c_mule_flag (client_id, account_type, account_id, account_no, source, reports_count, reason, status, flagged_by,
flagged_at)
VALUES (?,?,?,?,?,?,?,'SUSPECTED',?,?)
RETURNING id
""", Long.class, h.get("client_id"), h.get("account_type"), h.get("account_id"), h.get("account_no"), src,
reportsOn(h), trim(reason, 1000), name(user), now());
}
/** Officer's review: CONFIRMED (a mule; report it) or CLEARED (not a mule). */
@Transactional
public void reviewMule(final Long id, final String outcome, final String remarks, final AppUser user) {
final String o = upper(outcome);
if (!MULE_REVIEW.contains(o)) {
throw error("mule.outcome", "Outcome must be CONFIRMED or CLEARED.");
}
if (blank(remarks)) {
throw error("mule.remarks", "Record the basis of the review (EDD, LEA confirmation...).");
}
final int n = jdbcTemplate.update("""
UPDATE m_i4c_mule_flag SET status = ?, reviewed_by = ?, reviewed_at = ?, review_remarks = ? WHERE id = ? AND status <> 'CLEARED'
""", o, name(user), now(), trim(remarks, 1000), id);
if (n == 0) {
throw error("mule.not.found", "Mule flag " + id + " not found or already cleared.");
}
}
// ── Fraud reporting (police, RBI FMR, FIU-IND STR) ───────────────────────────────────────
/**
* Opens the fraud case on the hold's account: STR to FIU-IND is due 7 days from the suspicion (PML Rules). Before the
* account holder is classified as fraudulent the UCB Fraud Risk Management Directions, 2026 require a show-cause
* notice with at least 21 days to respond and a reasoned order; the FMR to RBI is then due within 14 days.
*/
@Transactional
public Long openFraudReport(final Long holdId, final AppUser user) {
final Map<String, Object> h = hold(holdId);
final List<Long> open = jdbcTemplate.queryForList(
"SELECT id FROM m_i4c_fraud_report WHERE account_no = ? AND status <> 'CLOSED'", Long.class, h.get("account_no"));
if (!open.isEmpty()) {
return open.get(0);
}
final Map<String, Object> agg = jdbcTemplate.queryForMap("""
SELECT STRING_AGG(DISTINCT acknowledgement_no, ' ') AS acks, COALESCE(SUM(disputed_amount),0) AS amount
FROM m_i4c_hold WHERE account_type = ? AND account_id = ?
""", h.get("account_type"), h.get("account_id"));
final LocalDate today = DateUtils.getBusinessLocalDate();
final List<Long> mule = jdbcTemplate.queryForList(
"SELECT id FROM m_i4c_mule_flag WHERE account_type = ? AND account_id = ? AND status <> 'CLEARED'", Long.class,
h.get("account_type"), h.get("account_id"));
final Timestamp now = now();
return jdbcTemplate.queryForObject("""
INSERT INTO m_i4c_fraud_report (hold_id, mule_flag_id, client_id, account_no, acknowledgement_nos, amount, str_due_on, status,
created_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,'SHOW_CAUSE',?,?,?)
RETURNING id
""", Long.class, holdId, mule.isEmpty() ? null : mule.get(0), h.get("client_id"), h.get("account_no"), agg.get("acks"),
agg.get("amount"), Date.valueOf(today.plusDays(STR_DAYS)), name(user), now, now);
}
/** The show-cause notice to the account holder: at least 21 days to respond. */
@Transactional
public void recordShowCause(final Long id, final LocalDate sentOn, final AppUser user) {
final Map<String, Object> r = fraudReport(id, "SHOW_CAUSE");
if (sentOn == null) {
throw error("fraud.show.cause", "Enter the date the show-cause notice was sent.");
}
if (r.get("classified_on") != null) {
throw error("fraud.classified", "Already classified.");
}
jdbcTemplate.update("UPDATE m_i4c_fraud_report SET show_cause_sent_on = ?, show_cause_due_on = ?, updated_at = ? WHERE id = ?",
Date.valueOf(sentOn), Date.valueOf(sentOn.plusDays(SHOW_CAUSE_DAYS)), now(), id);
}
@Transactional
public void recordShowCauseResponse(final Long id, final LocalDate respondedOn, final String response, final AppUser user) {
fraudReport(id, "SHOW_CAUSE");
if (respondedOn == null || blank(response)) {
throw error("fraud.response", "Enter the date and the account holder's response.");
}
jdbcTemplate.update("UPDATE m_i4c_fraud_report SET show_cause_responded_on = ?, show_cause_response = ?, updated_at = ? WHERE id = ?",
Date.valueOf(respondedOn), trim(response, 2000), now(), id);
}
/**
* The reasoned order: classified as fraud (FMR due in 14 days), or not (closed). Only after the response, or once the
* 21 days have passed without one.
*/
@Transactional
public void decideClassification(final Long id, final boolean fraud, final String orderRef, final LocalDate orderOn, final AppUser user) {
final Map<String, Object> r = fraudReport(id, "SHOW_CAUSE");
if (r.get("show_cause_sent_on") == null) {
throw error("fraud.show.cause.first", "Send the show-cause notice first (21 days to respond).");
}
final LocalDate due = ((Date) r.get("show_cause_due_on")).toLocalDate();
if (r.get("show_cause_responded_on") == null && DateUtils.getBusinessLocalDate().isBefore(due)) {
throw error("fraud.show.cause.window", "The account holder has until " + due + " to respond.");
}
if (orderOn == null || blank(orderRef)) {
throw error("fraud.order", "Enter the reasoned order's reference and date.");
}
if (fraud) {
jdbcTemplate.update("""
UPDATE m_i4c_fraud_report SET status = 'OPEN', order_ref = ?, order_on = ?, classified_on = ?, fmr_due_on = ?, updated_at = ?
WHERE id = ?
""", trim(orderRef, 200), Date.valueOf(orderOn), Date.valueOf(orderOn), Date.valueOf(orderOn.plusDays(FMR_DAYS)), now(), id);
} else {
jdbcTemplate.update("UPDATE m_i4c_fraud_report SET status = 'CLOSED', order_ref = ?, order_on = ?, remarks = ?, updated_at = ? WHERE id = ?",
trim(orderRef, 200), Date.valueOf(orderOn), "Not classified as fraud after the show-cause", now(), id);
}
}
private Map<String, Object> fraudReport(final Long id, final String status) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_fraud_report WHERE id = ? FOR UPDATE", id);
if (rows.isEmpty()) {
throw error("fraud.report.not.found", "Fraud report " + id + " not found.");
}
if (!status.equals(rows.get(0).get("status"))) {
throw error("fraud.report.status", "Not possible while the case is " + rows.get(0).get("status") + ".");
}
return rows.get(0);
}
/** Records what was filed: FMR (RBI), STR (FIU-IND), police / LEA complaint. Closed when all three are filed. */
@Transactional
public void updateFraudReport(final Long id, final Map<String, Object> f, final AppUser user) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_fraud_report WHERE id = ? FOR UPDATE", id);
if (rows.isEmpty()) {
throw error("fraud.report.not.found", "Fraud report " + id + " not found.");
}
final Map<String, Object> r = rows.get(0);
if (r.get("classified_on") == null && (f.containsKey("fmrRef") && !blank(text(f, "fmrRef")))) {
throw error("fraud.fmr.before.classification", "The FMR is filed after the reasoned order classifies the fraud.");
}
final String fmrRef = pick(f, "fmrRef", r.get("fmr_ref"));
final Date fmrOn = pickDate(f, "fmrFiledOn", r.get("fmr_filed_on"));
final String strRef = pick(f, "strRef", r.get("str_ref"));
final Date strOn = pickDate(f, "strFiledOn", r.get("str_filed_on"));
final String policeRef = pick(f, "policeRef", r.get("police_ref"));
final Date policeOn = pickDate(f, "policeReportedOn", r.get("police_reported_on"));
if ((fmrRef == null) != (fmrOn == null) || (strRef == null) != (strOn == null) || (policeRef == null) != (policeOn == null)) {
throw error("fraud.report.pair", "Give each reference with its date (FMR, STR, police).");
}
final boolean done = fmrRef != null && strRef != null && policeRef != null && r.get("classified_on") != null;
jdbcTemplate.update("""
UPDATE m_i4c_fraud_report SET fmr_ref = ?, fmr_filed_on = ?, str_ref = ?, str_filed_on = ?, police_ref = ?, police_reported_on = ?,
remarks = COALESCE(?, remarks), status = ?, updated_at = ?
WHERE id = ?
""", trim(fmrRef, 100), fmrOn, trim(strRef, 100), strOn, trim(policeRef, 200), policeOn, trim(text(f, "remarks"), 2000),
done ? "CLOSED" : (String) r.get("status"), now(), id);
}
// ── Linked repayments ───────────────────────────────────────────────────────────────────
/**
* Looks, for active holds that could not hold the whole disputed amount, for transfers from the held account between
* the reported transaction date and the hold to a loan here (fraud money that repaid a loan, SOP 9.1(v)) or to another
* savings / current account in this bank (the next layer of the trail is with us). A hold that found the whole
* disputed amount raises none: the fraud money did not move on, and later debits are the customer's own money.
* Each is raised once as an OPEN alert. Run by the I4C Hold Review job.
*/
public int scanLinkedRepayments() {
return scanLoanRepayments() + scanInternalOnward();
}
private int scanInternalOnward() {
return jdbcTemplate.update("""
INSERT INTO m_i4c_linked_alert (hold_id, kind, target_type, target_id, target_no, amount, transfer_id, moved_on, status, created_at)
SELECT h.id, 'INTERNAL_TRANSFER', CASE WHEN d.to_savings_account_id IS NOT NULL THEN 'SAVINGS' ELSE 'CURRENT' END,
COALESCE(d.to_savings_account_id, d.to_current_account_id), COALESCE(sa.account_no, ca.account_no), t.amount, t.id,
t.transaction_date, 'OPEN', CURRENT_TIMESTAMP
FROM m_i4c_hold h
JOIN m_i4c_incident i ON i.id = h.incident_id
JOIN m_account_transfer_details d
ON (d.to_savings_account_id IS NOT NULL OR d.to_current_account_id IS NOT NULL)
AND ((h.account_type = 'SAVINGS' AND d.from_savings_account_id = h.account_id)
OR (h.account_type = 'CURRENT' AND d.from_current_account_id = h.account_id))
JOIN m_account_transfer_transaction t ON t.account_transfer_details_id = d.id AND t.is_reversed = false
LEFT JOIN m_savings_account sa ON sa.id = d.to_savings_account_id
LEFT JOIN m_current_account ca ON ca.id = d.to_current_account_id
WHERE h.status = 'ACTIVE' AND h.layer >= 1 AND COALESCE(i.outcome, '') <> 'HELD'
AND t.transaction_date BETWEEN COALESCE(i.transaction_date, CAST(h.held_at AS DATE)) AND CAST(h.held_at AS DATE)
AND NOT EXISTS (SELECT 1 FROM m_i4c_linked_alert a WHERE a.hold_id = h.id AND a.transfer_id = t.id)
""");
}
private int scanLoanRepayments() {
return jdbcTemplate.update("""
INSERT INTO m_i4c_linked_alert (hold_id, kind, target_type, target_id, target_no, amount, transfer_id, moved_on, status, created_at)
SELECT h.id, 'LOAN_REPAYMENT', 'LOAN', d.to_loan_account_id, l.account_no, t.amount, t.id, t.transaction_date, 'OPEN', CURRENT_TIMESTAMP
FROM m_i4c_hold h
JOIN m_i4c_incident i ON i.id = h.incident_id
JOIN m_account_transfer_details d
ON d.to_loan_account_id IS NOT NULL
AND ((h.account_type = 'SAVINGS' AND d.from_savings_account_id = h.account_id)
OR (h.account_type = 'CURRENT' AND d.from_current_account_id = h.account_id))
JOIN m_account_transfer_transaction t ON t.account_transfer_details_id = d.id AND t.is_reversed = false
JOIN m_loan l ON l.id = d.to_loan_account_id
WHERE h.status = 'ACTIVE' AND h.layer >= 1 AND COALESCE(i.outcome, '') <> 'HELD'
AND t.transaction_date BETWEEN COALESCE(i.transaction_date, CAST(h.held_at AS DATE)) AND CAST(h.held_at AS DATE)
AND NOT EXISTS (SELECT 1 FROM m_i4c_linked_alert a WHERE a.hold_id = h.id AND a.transfer_id = t.id)
""");
}
@Transactional
public void actionAlert(final Long id, final String outcome, final String remarks, final AppUser user) {
final String o = upper(outcome);
if (!ALERT_OUTCOMES.contains(o)) {
throw error("alert.outcome", "Outcome must be ACTIONED or DISMISSED.");
}
if (blank(remarks)) {
throw error("alert.remarks", "Say what was done (amount held on the loan, linked account told, or why dismissed).");
}
final int n = jdbcTemplate.update("""
UPDATE m_i4c_linked_alert SET status = ?, remarks = ?, actioned_by = ?, actioned_at = ? WHERE id = ? AND status = 'OPEN'
""", o, trim(remarks, 1000), name(user), now(), id);
if (n == 0) {
throw error("alert.not.open", "Alert " + id + " is not open.");
}
}
// ── 90-day lapse (SOP: from the bank's grievance; LEA told 15 days before) ─────────────────
/** Starts the lapse clock when the bank raises the account holder's grievance. */
public void startLapseClock(final Long holdId, final LocalDate from) {
final LocalDate lapse = from.plusDays(LAPSE_DAYS);
jdbcTemplate.update("UPDATE m_i4c_hold SET lapse_on = COALESCE(lapse_on, ?), lea_notice_due_on = COALESCE(lea_notice_due_on, ?) WHERE id = ?",
Date.valueOf(lapse), Date.valueOf(lapse.minusDays(LEA_NOTICE_DAYS_BEFORE)), holdId);
}
@Transactional
public void recordLeaNotice(final Long holdId, final LocalDate sentOn, final String reference, final AppUser user) {
hold(holdId);
if (sentOn == null || blank(reference)) {
throw error("lea.notice", "Enter the date and reference of the notice to the LEA.");
}
jdbcTemplate.update("UPDATE m_i4c_hold SET lea_notice_sent_on = ?, lea_notice_ref = ? WHERE id = ?", Date.valueOf(sentOn),
trim(reference, 200), holdId);
}
/** The LEA extended the hold beyond the lapse date (a lawful direction). */
@Transactional
public void recordExtension(final Long holdId, final LocalDate until, final String reference, final AppUser user) {
hold(holdId);
if (until == null || blank(reference)) {
throw error("lea.extension", "Enter the extended date and the LEA's order reference.");
}
jdbcTemplate.update("UPDATE m_i4c_hold SET extension_until = ?, extension_ref = ? WHERE id = ?", Date.valueOf(until),
trim(reference, 200), holdId);
}
/** True when the hold may be released without an order: its review or lapse date passed with no extension in force. */
public static boolean lapsed(final Map<String, Object> hold, final LocalDate today) {
final Object extension = hold.get("extension_until");
if (extension != null && !toLocal(extension).isBefore(today)) {
return false;
}
final Object lapse = hold.get("lapse_on");
final Object review = hold.get("review_due_on");
return (lapse != null && !toLocal(lapse).isAfter(today)) || (review != null && !toLocal(review).isAfter(today));
}
// ── Nodal / pool / escrow accounts excluded from I4C holds ───────────────────────────────
public static final Set<String> EXCLUSION_KINDS = Set.of("NODAL", "POOL", "ESCROW");
@Transactional
public Long addExclusion(final AccountRef account, final String kind, final String reason, final AppUser user) {
final String k = upper(kind);
if (!EXCLUSION_KINDS.contains(k)) {
throw error("exclusion.kind", "Kind must be NODAL, POOL or ESCROW.");
}
if (blank(reason)) {
throw error("exclusion.reason", "Say what the account is (merchant nodal account, payment aggregator pool...).");
}
final List<Long> existing = jdbcTemplate.queryForList(
"SELECT id FROM m_i4c_hold_exclusion WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'", Long.class, account.type(),
account.id());
if (!existing.isEmpty()) {
throw error("exclusion.exists", "Account " + account.accountNo() + " is already excluded.");
}
return jdbcTemplate.queryForObject("""
INSERT INTO m_i4c_hold_exclusion (account_type, account_id, account_no, kind, reason, status, added_by, added_at)
VALUES (?,?,?,?,?,'ACTIVE',?,?)
RETURNING id
""", Long.class, account.type(), account.id(), account.accountNo(), k, trim(reason, 500), name(user), now());
}
@Transactional
public void removeExclusion(final Long id, final AppUser user) {
final int n = jdbcTemplate.update("""
UPDATE m_i4c_hold_exclusion SET status = 'REMOVED', removed_by = ?, removed_at = ? WHERE id = ? AND status = 'ACTIVE'
""", name(user), now(), id);
if (n == 0) {
throw error("exclusion.not.found", "Exclusion " + id + " not found or already removed.");
}
}
// ── IFSC -> I4C Bank Code map ──────────────────────────────────────────────────────────
@Transactional
public Long mapIfsc(final String ifscKey, final Integer bankCode, final String note, final AppUser user) {
final String key = upper(ifscKey);
if (!key.matches("^[A-Z]{4}$") && !key.matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
throw error("ifsc.key", "Give a 4-letter IFSC prefix or a full 11-character IFSC.");
}
final Integer known = bankCode == null ? 0
: jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_i4c_bank_master WHERE bank_code = ?", Integer.class, bankCode);
if (known == null || known == 0) {
throw error("ifsc.bank.code", "Bank code " + bankCode + " is not in the I4C Bank Code Master.");
}
jdbcTemplate.update("DELETE FROM m_i4c_ifsc_map WHERE ifsc_key = ?", key);
return jdbcTemplate.queryForObject("""
INSERT INTO m_i4c_ifsc_map (ifsc_key, bank_code, note, added_by, added_at) VALUES (?,?,?,?,?) RETURNING id
""", Long.class, key, bankCode, trim(note, 300), name(user), now());
}
@Transactional
public void unmapIfsc(final Long id) {
jdbcTemplate.update("DELETE FROM m_i4c_ifsc_map WHERE id = ?", id);
}
// ── helpers ─────────────────────────────────────────────────────────────────────────────
private Map<String, Object> hold(final Long holdId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", holdId);
if (rows.isEmpty()) {
throw error("hold.not.found", "I4C hold " + holdId + " not found.");
}
return rows.get(0);
}
private Integer reportsOn(final Map<String, Object> h) {
return jdbcTemplate.queryForObject("SELECT COUNT(DISTINCT acknowledgement_no) FROM m_i4c_hold WHERE account_type = ? AND account_id = ?",
Integer.class, h.get("account_type"), h.get("account_id"));
}
private static LocalDate toLocal(final Object value) {
return value instanceof Date d ? d.toLocalDate() : LocalDate.parse(String.valueOf(value));
}
private static String pick(final Map<String, Object> f, final String key, final Object current) {
if (f.containsKey(key)) {
final String v = text(f, key);
return blank(v) ? null : v.trim();
}
return current == null ? null : String.valueOf(current);
}
private static Date pickDate(final Map<String, Object> f, final String key, final Object current) {
if (f.containsKey(key)) {
final String v = text(f, key);
if (blank(v)) {
return null;
}
try {
return Date.valueOf(LocalDate.parse(v.trim()));
} catch (final RuntimeException e) {
throw error("date.invalid", key + " must be a date in yyyy-MM-dd form.", e);
}
}
return (Date) current;
}
private static String text(final Map<String, Object> f, final String key) {
final Object v = f.get(key);
return v == null ? null : String.valueOf(v);
}
private static Timestamp now() {
return Timestamp.from(Instant.now());
}
private static String name(final AppUser user) {
return user == null ? "system" : trim(user.getDisplayName(), 100);
}
private static boolean blank(final String value) {
return value == null || value.isBlank();
}
private static String upper(final String value) {
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
}
private static String trim(final String value, final int max) {
if (value == null || value.isBlank()) {
return null;
}
final String t = value.trim();
return t.length() <= max ? t : t.substring(0, max);
}
private static PlatformDataIntegrityException error(final String code, final String message, final Object... cause) {
return new PlatformDataIntegrityException("error.msg.i4c." + code, message, cause);
}
}
@@ -23,7 +23,6 @@ import java.sql.Date;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
@@ -311,16 +310,16 @@ public class I4cTransactionTracer {
}
/**
* "yyyy-MM-dd HH:mm:ss" in the bank's time zone. Savings rows record the moment in UTC (created_on_utc), current
* rows in local time (created_date); the business date is kept and only the time of day is taken from the stamp.
* "yyyy-MM-dd HH:mm:ss" in the bank's time zone. Savings rows record the moment with its zone (created_on_utc, a
* timestamptz), current rows in local time (created_date); either way the JDBC Timestamp already holds the right
* instant, so it is only moved into the tenant's zone. The business date is kept and only the time of day is taken
* from the stamp.
*/
static String dateTime(final Object transactionDate, final Object createdAt) {
final LocalDate date = toLocalDate(transactionDate);
if (createdAt instanceof Timestamp ts) {
final LocalDateTime created = ts.toLocalDateTime();
final LocalDateTime local = created.atOffset(ZoneOffset.UTC).atZoneSameInstant(tenantZone()).toLocalDateTime();
final LocalDateTime chosen = local.toLocalDate().equals(date) ? local : created;
return (date == null ? chosen : date.atTime(chosen.toLocalTime())).withNano(0).format(DATE_TIME);
final LocalDateTime local = ts.toInstant().atZone(tenantZone()).toLocalDateTime();
return (date == null ? local : date.atTime(local.toLocalTime())).withNano(0).format(DATE_TIME);
}
if (createdAt instanceof java.time.OffsetDateTime odt) {
final LocalDateTime local = odt.atZoneSameInstant(tenantZone()).toLocalDateTime();
@@ -33,7 +33,9 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cComplaintParser;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cApiLogService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRequestService;
import org.springframework.stereotype.Component;
@@ -43,7 +45,9 @@ import org.springframework.stereotype.Component;
*
* <ul>
* <li>{@code auth/token}, {@code bank/response} - what the bank calls when its outbound base URL points here.</li>
* <li>{@code push} - builds a complaint from a few fields and feeds it in exactly as an I4C push would arrive.</li>
* <li>{@code push} - builds a complaint from a few fields and feeds it in exactly as an I4C push would arrive. It skips
* the inbound token check, so it is written to the API audit trail itself, as I4C to bank on
* {@code internal-simulator/complaints}.</li>
* <li>{@code received} - the responses the bank has sent, newest first.</li>
* </ul>
*/
@@ -58,6 +62,7 @@ public class I4cSimulatorApiResource {
private final I4cProperties properties;
private final I4cSimulator simulator;
private final I4cRequestService requestService;
private final I4cApiLogService apiLog;
@POST
@Path("auth/token")
@@ -105,6 +110,7 @@ public class I4cSimulatorApiResource {
if (!properties.getSimulator().isEnabled()) {
return notFound();
}
final long start = System.nanoTime();
final JsonObject in = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
final JsonObject complaint = I4cSimulator.buildComplaint(in);
final I4cComplaintParser.Result parsed = I4cComplaintParser.parse(complaint);
@@ -114,15 +120,25 @@ public class I4cSimulatorApiResource {
final JsonArray errors = new JsonArray();
parsed.errors().forEach(errors::add);
out.add("errors", errors);
logPush(400, I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED, complaint, out, start, http);
return Response.status(400).type(MediaType.APPLICATION_JSON).entity(out.toString()).build();
}
final I4cRequestService.Accepted accepted = requestService.accept(parsed.complaint(), complaint.toString(), "simulator");
out.addProperty("requestId", accepted.requestId());
out.addProperty("job_id", accepted.jobId());
out.addProperty("duplicate", accepted.duplicate());
logPush(200, I4cCodes.ACK_SUCCESS, complaint, out, start, http);
return ok(out.toString());
}
/** The audit-trail row a real push would have left on the inbound complaints endpoint. */
private void logPush(final int status, final String code, final JsonObject complaint, final JsonObject out, final long start,
final HttpServletRequest http) {
final String ack = complaint.has("acknowledgement_no") ? complaint.get("acknowledgement_no").getAsString() : null;
apiLog.record(I4cApiLogService.IN, "internal-simulator/complaints", status, code, ack, (System.nanoTime() - start) / 1_000_000L,
http == null ? null : http.getRemoteAddr(), complaint.toString(), out.toString(), null);
}
private static Response ok(final String json) {
return Response.ok(json, MediaType.APPLICATION_JSON).build();
}
@@ -165,6 +165,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.jdbcTemplate.update("delete from m_client_address where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_address where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_client_kyc where client_id = ?", clientId);
// A pending client can carry an edit request still waiting for a checker; its foreign key to m_client would
// otherwise stop the delete (surfacing as "Unknown data integrity issue"). Deleting the client voids it.
this.jdbcTemplate.update("delete from m_client_pending_edit where client_id = ?", clientId);
this.clientRepository.delete(client);
this.clientRepository.flush();
@@ -113,6 +113,7 @@ public class KycRestrictionGuard {
* (tracking flag and dormancy days): a current account is dormant on its product's schedule, not a savings one's.
*/
public void checkCurrentAccount(final Long currentAccountId, final Operation operation) {
checkCurrentAccountBlock(currentAccountId, operation);
checkCurrentAccountDormancy(currentAccountId, operation);
enforceKycStage(currentAccountDaysOverdue(currentAccountId), operation);
}
@@ -173,6 +174,30 @@ public class KycRestrictionGuard {
refuseIfDormant(rows, operation, "savings");
}
/**
* Honours a block on a current account the way a savings account honours its own: BLOCK (400) stops every
* movement, BLOCK_DEBIT (600) stops money leaving, BLOCK_CREDIT (500) stops money arriving. Until this check the
* current-account sub-status was only displayed, so a debit freeze placed on a current account (an LEA / court
* direction, an I4C freeze) did not stop anything.
*/
private void checkCurrentAccountBlock(final Long currentAccountId, final Operation operation) {
if (currentAccountId == null) {
return;
}
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT sub_status_enum, reason_for_block FROM m_current_account WHERE id = ?", currentAccountId);
if (rows.isEmpty() || rows.get(0).get("sub_status_enum") == null) {
return;
}
final int subStatus = ((Number) rows.get(0).get("sub_status_enum")).intValue();
final boolean outgoing = operation != Operation.CREDIT;
if (subStatus == 400 || (outgoing && subStatus == 600) || (!outgoing && subStatus == 500)) {
final Object reason = rows.get(0).get("reason_for_block");
throw new PlatformDataIntegrityException("error.msg.current.account.blocked." + operation.name().toLowerCase(),
operation.getLabel() + " is not allowed: the current account is blocked" + (reason == null ? "." : " (" + reason + ")."));
}
}
private void checkCurrentAccountDormancy(final Long currentAccountId, final Operation operation) {
if (currentAccountId == null || operation == Operation.CREDIT) {
return;
@@ -66,10 +66,10 @@ public class FundTransfer extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "cashier_user_id", nullable = false)
private Long cashierUserId;
@Column(name = "client_id", nullable = false)
@Column(name = "client_id")
private Long clientId;
@Column(name = "payer_account_id", nullable = false)
@Column(name = "payer_account_id")
private Long payerAccountId;
@Column(name = "payer_account_number", nullable = false, length = 64)
@@ -110,4 +110,28 @@ public class FundTransfer extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "settled_at")
private LocalDateTime settledAt;
/** How the money was parked: ACCOUNT (legacy suspense savings account), GL (outward suspense GL), GL_SOURCE. */
@Column(name = "park_mode", length = 12)
private String parkMode;
/** For a transfer paid from a GL (e.g. I4C restoration payable) rather than a customer account. */
@Column(name = "source_gl_account_id")
private Long sourceGlAccountId;
@Column(name = "source_office_id")
private Long sourceOfficeId;
/** What the GL-sourced payment is for, e.g. the I4C restoration id. */
@Column(name = "source_reference", length = 64)
private String sourceReference;
@Column(name = "park_journal_ref", length = 64)
private String parkJournalRef;
@Column(name = "settlement_journal_ref", length = 64)
private String settlementJournalRef;
@Column(name = "reversal_journal_ref", length = 64)
private String reversalJournalRef;
}
@@ -0,0 +1,54 @@
/**
* 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.fundtransfer.service;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Refuses a fund transfer from an account whose digital channels are suspended (m_i4c_channel_block): the MHA
* NCRP-CFCFRMS SOP lets a bank suspend RTGS, NEFT, IMPS and other digital banking on a repeatedly reported account.
*/
@Service
@RequiredArgsConstructor
public class ChannelBlockGuard {
private final JdbcTemplate jdbcTemplate;
public void requireOpen(final String accountType, final Long accountId, final String channel) {
if (accountId == null || channel == null) {
return;
}
final List<Map<String, Object>> blocks = jdbcTemplate.queryForList(
"SELECT channels, reason FROM m_i4c_channel_block WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'", accountType,
accountId);
for (final Map<String, Object> b : blocks) {
final String channels = String.valueOf(b.get("channels"));
if (Arrays.stream(channels.split(",")).map(String::trim).anyMatch(channel::equalsIgnoreCase)) {
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.channel.suspended",
channel + " is suspended on this account (" + b.get("reason") + "). Contact the branch.", accountId);
}
}
}
}
@@ -0,0 +1,204 @@
/**
* 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.fundtransfer.service;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.account.service.AccountBookDebitService;
import org.apache.fineract.portfolio.clearing.service.ClearingGlAccountResolverService;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* The books of an outward (OFF_US) NEFT / IMPS / RTGS transfer.
*
* <p>
* With the GL roles {@code FUNDTRANSFER_OUTWARD_SUSPENSE} (liability) and {@code FUNDTRANSFER_SETTLEMENT} (balance
* with the sponsor bank / RBI, an asset) mapped on the Clearing GL Configuration screen:
*
* <pre>
* park Dr payer Savings Control (book debit) Cr OUTWARD_SUSPENSE -- or Dr source GL for a GL-sourced transfer
* settle Dr OUTWARD_SUSPENSE Cr SETTLEMENT -- when the switch reports SUCCESS
* reverse Dr OUTWARD_SUSPENSE (or SETTLEMENT once settled) Cr payer Savings Control (book credit) / source GL
* </pre>
*
* The money therefore leaves customer deposits when it is sent and leaves the bank when it settles, and the suspense
* GL holds only what is in flight. Without the suspense GL mapped, the earlier behaviour (a savings "suspense" account,
* no settlement entry) is kept so nothing changes until the bank maps it; a GL-sourced transfer needs the GL mode.
* Every posting joins the caller's transaction.
*/
@Service
@RequiredArgsConstructor
public class FundTransferLedgerService {
public static final String OUTWARD_SUSPENSE = "FUNDTRANSFER_OUTWARD_SUSPENSE";
public static final String SETTLEMENT = "FUNDTRANSFER_SETTLEMENT";
public static final String MODE_ACCOUNT = "ACCOUNT";
public static final String MODE_GL = "GL";
public static final String MODE_GL_SOURCE = "GL_SOURCE";
private static final Logger log = LoggerFactory.getLogger(FundTransferLedgerService.class);
private final SuspenseGlService legacySuspense;
private final ClearingGlAccountResolverService glResolver;
private final AccountingProcessorHelper accounting;
private final AccountBookDebitService bookEntries;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
/** True when outward transfers are booked through the outward suspense GL. */
public boolean glMode() {
return glResolver.resolveClearingGlAccountId(OUTWARD_SUSPENSE) != null;
}
/** Takes the money for an outward transfer: from the payer's account, or from the source GL. */
public void park(final FundTransfer t) {
final Long suspense = glResolver.resolveClearingGlAccountId(OUTWARD_SUSPENSE);
final LocalDate date = DateUtils.getBusinessLocalDate();
if (t.getSourceGlAccountId() != null) {
if (suspense == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.gl.mode.required",
"A payment from a GL needs the FUNDTRANSFER_OUTWARD_SUSPENSE GL mapped (Clearing GL Configuration).");
}
final String ref = t.getTxnRef() + "-P";
accounting.createGlToGlJournalEntries(t.getSourceOfficeId(), currency(null), t.getSourceGlAccountId(), suspense, null, ref, date,
t.getAmount());
t.setParkMode(MODE_GL_SOURCE);
t.setParkJournalRef(ref);
return;
}
if (suspense == null) {
final Long legId = legacySuspense.parkInSuspense(t);
t.setParkMode(MODE_ACCOUNT);
if (legId != null) {
t.setInternalTxnId(legId);
}
return;
}
final Long payerId = t.getPayerAccountId();
final Long control = controlGl(payerId);
final Long officeId = officeOf(payerId);
final long txnId = bookEntries.debitSavings(payerId, t.getAmount(), officeId, date, userId());
final String ref = t.getTxnRef() + "-P";
accounting.createGlToGlJournalEntries(officeId, currency(payerId), control, suspense, payerId, ref, date, t.getAmount());
t.setParkMode(MODE_GL);
t.setInternalTxnId(txnId);
t.setParkJournalRef(ref);
}
/** The switch reported SUCCESS: the money has left the bank, so the suspense is cleared to the settlement account. */
public void settle(final FundTransfer t) {
if (!isGlParked(t) || t.getSettlementJournalRef() != null) {
return;
}
final Long suspense = glResolver.resolveClearingGlAccountId(OUTWARD_SUSPENSE);
final Long settlement = glResolver.resolveClearingGlAccountId(SETTLEMENT);
if (suspense == null || settlement == null) {
log.warn("[FundTransferLedger] txnRef={} succeeded but {} / {} is not mapped; it stays in outward suspense until settled",
t.getTxnRef(), OUTWARD_SUSPENSE, SETTLEMENT);
return;
}
final String ref = t.getTxnRef() + "-S";
accounting.createGlToGlJournalEntries(officeFor(t), currency(t.getPayerAccountId()), suspense, settlement, t.getPayerAccountId(), ref,
DateUtils.getBusinessLocalDate(), t.getAmount());
t.setSettlementJournalRef(ref);
}
/**
* The transfer failed, was rejected after parking, or was recalled: the money goes back where it came from. From
* the settlement account once settled (the other bank returned it), otherwise from the outward suspense.
*/
public void reverse(final FundTransfer t) {
if (!isGlParked(t)) {
if (MODE_ACCOUNT.equals(t.getParkMode()) || t.getParkMode() == null) {
legacySuspense.reverseFromSuspense(t);
}
return;
}
if (t.getReversalJournalRef() != null) {
return;
}
final Long from = glResolver.resolveClearingGlAccountId(t.getSettlementJournalRef() != null ? SETTLEMENT : OUTWARD_SUSPENSE);
if (from == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.gl.unmapped",
"The outward suspense / settlement GL is no longer mapped; map it before reversing " + t.getTxnRef() + ".");
}
final LocalDate date = DateUtils.getBusinessLocalDate();
final String ref = t.getTxnRef() + "-R";
if (MODE_GL_SOURCE.equals(t.getParkMode())) {
accounting.createGlToGlJournalEntries(t.getSourceOfficeId(), currency(null), from, t.getSourceGlAccountId(), null, ref, date,
t.getAmount());
} else {
final Long payerId = t.getPayerAccountId();
final Long officeId = officeOf(payerId);
bookEntries.creditSavings(payerId, t.getAmount(), officeId, date, userId());
accounting.createGlToGlJournalEntries(officeId, currency(payerId), from, controlGl(payerId), payerId, ref, date, t.getAmount());
}
t.setReversalJournalRef(ref);
}
private boolean isGlParked(final FundTransfer t) {
return MODE_GL.equals(t.getParkMode()) || MODE_GL_SOURCE.equals(t.getParkMode());
}
private Long officeFor(final FundTransfer t) {
return t.getSourceOfficeId() != null ? t.getSourceOfficeId() : officeOf(t.getPayerAccountId());
}
private Long controlGl(final Long savingsId) {
final Long control = glResolver.resolveControlGlAccountId("SAVINGS", savingsId);
if (control == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.control.gl.missing",
"The payer's savings product has no Savings Control GL mapped.", savingsId);
}
return control;
}
private Long officeOf(final Long savingsId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT c.office_id FROM m_savings_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", savingsId);
if (rows.isEmpty() || rows.get(0).get("office_id") == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.payer.office", "Payer account has no branch.", savingsId);
}
return ((Number) rows.get(0).get("office_id")).longValue();
}
private String currency(final Long savingsId) {
if (savingsId == null) {
return "INR";
}
final List<String> codes = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class,
savingsId);
return codes.isEmpty() || codes.get(0) == null ? "INR" : codes.get(0);
}
private Long userId() {
return context.authenticatedUser().getId();
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.fundtransfer.service;
import java.math.BigDecimal;
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
import org.apache.fineract.portfolio.fundtransfer.data.VerifyRequestData;
import org.apache.fineract.portfolio.fundtransfer.data.VerifyResponseData;
@@ -33,4 +34,12 @@ public interface FundTransferWritePlatformService {
TransferResponseData reject(String txnRef, String reason);
TransferResponseData recall(String txnRef, String reason);
/**
* An outward NEFT / IMPS / RTGS paid from a bank GL rather than a customer account (an I4C restoration payable).
* Already approved by the caller's own maker-checker, so it runs at once; the channel's time window and the payee
* screening still apply. Needs the outward suspense GL mapped.
*/
TransferResponseData createFromGl(String channel, Long sourceGlAccountId, Long sourceOfficeId, String sourceReference,
String payeeAccountNumber, String payeeIfsc, String payeeName, BigDecimal amount, String remarks);
}
@@ -76,7 +76,8 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
private final ImpsSwitchClient impsSwitchClient;
private final SanctionsScreeningService sanctionsScreeningService;
private final TransferChannelWindowValidator windowValidator;
private final SuspenseGlService suspenseGl;
private final FundTransferLedgerService ledger;
private final ChannelBlockGuard channelBlockGuard;
private final AmlFundTransferEvaluationService amlFundTransferEvaluationService;
// ─── /verify ──────────────────────────────────────────────────────────
@@ -97,6 +98,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
if (clientId == null) {
throw new IllegalArgumentException("payer account is not owned by a client");
}
channelBlockGuard.requireOpen("SAVINGS", payer.getId(), channel.name());
ResolvedLimits limits = limitResolver.resolve(clientId, channel);
validateAmountAgainstLimits(request.getAmount(), limits);
@@ -175,6 +177,51 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
return FundTransferReadPlatformServiceImpl.toData(saved);
}
// ─── outward paid from a GL (I4C restoration) ─────────────────────────
@Override
@Transactional
public TransferResponseData createFromGl(String channelName, Long sourceGlAccountId, Long sourceOfficeId, String sourceReference,
String payeeAccountNumber, String payeeIfsc, String payeeName, BigDecimal amount, String remarks) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("amount must be > 0");
}
if (sourceGlAccountId == null || sourceOfficeId == null) {
throw new IllegalArgumentException("source GL and office are required");
}
TransferChannel channel = TransferChannel.from(channelName);
if (channel == TransferChannel.INTERNAL || resolveDirection(channel, payeeIfsc) != TransferDirection.OFF_US) {
throw new IllegalArgumentException("A payment from a GL goes only to another bank (IFSC not ours)");
}
LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
windowValidator.requireOpen(channel, LocalTime.from(now), now.getDayOfWeek());
screenOffUsPayee(payeeName, null);
AppUser actor = context.authenticatedUser();
FundTransfer transfer = new FundTransfer();
transfer.setTxnRef(generateRef());
transfer.setChannel(channel);
transfer.setDirection(TransferDirection.OFF_US);
transfer.setStatus(FundTransferStatus.VERIFIED);
transfer.setApprovalStatus(ApprovalStatus.AUTO);
transfer.setCashierUserId(actor.getId());
transfer.setPayerAccountNumber("GL-" + sourceGlAccountId);
transfer.setPayeeAccountNumber(payeeAccountNumber);
transfer.setPayeeIfsc(payeeIfsc == null ? null : payeeIfsc.trim().toUpperCase(java.util.Locale.ROOT));
transfer.setPayeeName(payeeName);
transfer.setAmount(amount);
transfer.setRemarks(remarks);
transfer.setInitiatedAt(now);
transfer.setSourceGlAccountId(sourceGlAccountId);
transfer.setSourceOfficeId(sourceOfficeId);
transfer.setSourceReference(sourceReference);
executeStrategy(transfer);
FundTransfer saved = fundTransferRepository.save(transfer);
eventRepository.save(FundTransferEvent.of(saved.getId(), null, saved.getStatus(), "INITIATED_FROM_GL", sourceReference, actor.getId()));
return FundTransferReadPlatformServiceImpl.toData(saved);
}
// ─── POST /v1/transfers/{ref}/approve ─────────────────────────────────
@Override
@@ -236,7 +283,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
}
AppUser actor = context.authenticatedUser();
String switchOutcome = notifySwitchOfRecall(t);
suspenseGl.reverseFromSuspense(t);
ledger.reverse(t);
t.setStatus(FundTransferStatus.REVERSED);
t.setResponseCode("RCL");
t.setResponseMessage(reason == null ? "Recall initiated by ops" : reason);
@@ -277,14 +324,12 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
// ─── internals ────────────────────────────────────────────────────────
private void executeStrategy(FundTransfer transfer) {
// A suspension placed between verify and approval still stops the money.
channelBlockGuard.requireOpen("SAVINGS", transfer.getPayerAccountId(), transfer.getChannel().name());
transfer.setStatus(FundTransferStatus.IN_PROGRESS);
Long suspenseLegId = null;
if (transfer.getDirection() == TransferDirection.OFF_US) {
suspenseLegId = suspenseGl.parkInSuspense(transfer);
if (suspenseLegId != null) {
transfer.setInternalTxnId(suspenseLegId);
}
ledger.park(transfer);
}
TransferStrategy strategy = router.strategyFor(transfer);
@@ -298,9 +343,12 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
}
if (transfer.getDirection() == TransferDirection.OFF_US && suspenseLegId != null
&& transfer.getStatus() == FundTransferStatus.FAILED) {
suspenseGl.reverseFromSuspense(transfer);
if (transfer.getDirection() == TransferDirection.OFF_US) {
if (transfer.getStatus() == FundTransferStatus.FAILED) {
ledger.reverse(transfer);
} else if (transfer.getStatus() == FundTransferStatus.SUCCESS) {
ledger.settle(transfer);
}
}
}
@@ -59,7 +59,7 @@ public class ImpsDeemedReconciliationService {
private final FundTransferRepository fundTransferRepository;
private final FundTransferEventRepository eventRepository;
private final ImpsSwitchClient switchClient;
private final SuspenseGlService suspenseGl;
private final FundTransferLedgerService ledger;
/** Picks all DEEMED rows in the window and tries to resolve each. Returns count touched. */
public int reconcileAll() {
@@ -110,7 +110,9 @@ public class ImpsDeemedReconciliationService {
}
transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
if (newStatus == FundTransferStatus.FAILED) {
suspenseGl.reverseFromSuspense(transfer);
ledger.reverse(transfer);
} else if (newStatus == FundTransferStatus.SUCCESS) {
ledger.settle(transfer);
}
fundTransferRepository.save(transfer);
eventRepository.save(FundTransferEvent.of(transfer.getId(), prev, newStatus, "RECONCILED",
@@ -54,7 +54,7 @@ public class NeftBatchProcessorService {
private final FundTransferRepository fundTransferRepository;
private final FundTransferEventRepository eventRepository;
private final ImpsSwitchClient switchClient;
private final SuspenseGlService suspenseGl;
private final FundTransferLedgerService ledger;
private final AmlFundTransferEvaluationService amlFundTransferEvaluationService;
@Value("${fineract.imps.our-default-ifsc:BANK0000001}")
@@ -106,7 +106,9 @@ public class NeftBatchProcessorService {
t.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
}
if (mapped == FundTransferStatus.FAILED) {
suspenseGl.reverseFromSuspense(t);
ledger.reverse(t);
} else if (mapped == FundTransferStatus.SUCCESS) {
ledger.settle(t);
}
fundTransferRepository.save(t);
eventRepository.save(FundTransferEvent.of(t.getId(), prev, mapped, "NEFT_BATCH_PROCESSED",
@@ -119,7 +121,7 @@ public class NeftBatchProcessorService {
t.setResponseCode("ERR");
t.setResponseMessage(ex.getMessage());
t.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
suspenseGl.reverseFromSuspense(t);
ledger.reverse(t);
fundTransferRepository.save(t);
eventRepository.save(FundTransferEvent.of(t.getId(), prev, FundTransferStatus.FAILED,
"NEFT_BATCH_ERROR", ex.toString(), null));
@@ -873,10 +873,8 @@ fineract.i4c.match.date-window-days=${FINERACT_I4C_MATCH_WINDOW_DAYS:2}
# Days a hold runs before it is due for review (MHA SOP: 90 days), and hours to intimate the customer.
fineract.i4c.hold.review-days=${FINERACT_I4C_HOLD_REVIEW_DAYS:90}
fineract.i4c.hold.intimation-hours=${FINERACT_I4C_HOLD_INTIMATION_HOURS:24}
# MRM money restoration. Outward NEFT / IMPS / RTGS restorations leave the suspect account for this pool savings
# account (a bank-owned client account) in the same transaction as the hold release, then go out from it. Empty = only
# restorations to accounts in this bank are possible.
fineract.i4c.restoration.pool-account-no=${FINERACT_I4C_RESTORATION_POOL_ACCOUNT:}
# MRM money restoration to another bank books through GLs mapped on Organisation > Clearing GL Configuration:
# I4C_RESTORATION_PAYABLE, FUNDTRANSFER_OUTWARD_SUSPENSE and FUNDTRANSFER_SETTLEMENT (see 0614_i4c_sop_and_outward_ledger).
# MRM manual: above this amount in a single complaint the IO must give an FIR.
fineract.i4c.restoration.fir-threshold=${FINERACT_I4C_RESTORATION_FIR_THRESHOLD:50000}
# Ack codes for a refused push: SECTION5 = always 11 "Structure validation failed"; SECTION7 = the specific section 7
@@ -182,4 +182,8 @@
<include file="parts/0611_i4c_bank_master_seed.xml" relativeToChangelogFile="true"/>
<include file="parts/0612_i4c_intimation_channels.xml" relativeToChangelogFile="true"/>
<include file="parts/0613_i4c_grm_mrm.xml" relativeToChangelogFile="true"/>
<include file="parts/0614_i4c_sop_and_outward_ledger.xml" relativeToChangelogFile="true"/>
<include file="parts/0615_i4c_exclusions_and_bank_holds.xml" relativeToChangelogFile="true"/>
<include file="parts/0616_i4c_rbi_2026_liability_and_show_cause.xml" relativeToChangelogFile="true"/>
<include file="parts/0617_i4c_ifsc_bank_code_map.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,236 @@
<?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.
-->
<!--
1. Outward fund-transfer ledger. Outward NEFT / IMPS / RTGS used to park the payer's money on a customer savings
account (FUNDTRANSFER_SUSPENSE) and never settle it, so money sent to other banks stayed in customer deposits.
With the GL roles below mapped (Organisation > Clearing GL Configuration) an outward transfer posts:
initiate Dr payer Savings Control Cr FUNDTRANSFER_OUTWARD_SUSPENSE
success Dr FUNDTRANSFER_OUTWARD_SUSPENSE Cr FUNDTRANSFER_SETTLEMENT (balance with sponsor bank / RBI)
fail Dr FUNDTRANSFER_OUTWARD_SUSPENSE Cr payer Savings Control (payer credited back)
recall Dr FUNDTRANSFER_SETTLEMENT Cr payer Savings Control (after settlement)
Unmapped, the old savings-suspense behaviour continues, so nothing changes until the bank maps them.
A transfer can also be sourced from a GL (I4C restoration payable) instead of a customer account.
2. I4C restoration through a payable GL instead of a pool savings account:
approval Dr suspect's Savings / Current Control Cr I4C_RESTORATION_PAYABLE
outward Dr I4C_RESTORATION_PAYABLE Cr FUNDTRANSFER_OUTWARD_SUSPENSE ... as above
3. MHA SOP (02.01.2026) items the CBS did not track: the 15-day credit to the victim after the IO's notice, the
suspect's 15-day challenge after a refund, the 90-day hold lapse counted from the grievance with the LEA told
15 days before, suspension of digital channels on repeatedly reported accounts, holds on loans / dues repaid
with fraud money, a mule-account register, fraud reporting (police, RBI FMR, FIU-IND STR), and the victim bank's
duties under RBI's customer-liability directions (shadow credit within 10 working days, 90-day resolution).
-->
<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.3.xsd">
<changeSet author="fineract" id="0614-1-fund-transfer-ledger">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_fund_transfer" columnName="park_mode"/></not>
</preConditions>
<addColumn tableName="m_fund_transfer">
<!-- ACCOUNT (legacy savings suspense), GL (payer debited to the outward suspense GL), GL_SOURCE (from a GL) -->
<column name="park_mode" type="VARCHAR(12)"/>
<column name="source_gl_account_id" type="BIGINT"/>
<column name="source_office_id" type="BIGINT"/>
<column name="source_reference" type="VARCHAR(64)"/>
<column name="park_journal_ref" type="VARCHAR(64)"/>
<column name="settlement_journal_ref" type="VARCHAR(64)"/>
<column name="reversal_journal_ref" type="VARCHAR(64)"/>
</addColumn>
<!-- A GL-sourced transfer has no payer account or client. -->
<dropNotNullConstraint tableName="m_fund_transfer" columnName="payer_account_id" columnDataType="BIGINT"/>
<dropNotNullConstraint tableName="m_fund_transfer" columnName="client_id" columnDataType="BIGINT"/>
<sql>
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
SELECT 'FUNDTRANSFER_OUTWARD_SUSPENSE', 'Outward NEFT / IMPS / RTGS awaiting settlement (liability)', NULL
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'FUNDTRANSFER_OUTWARD_SUSPENSE');
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
SELECT 'FUNDTRANSFER_SETTLEMENT', 'Settlement account for outward transfers: balance with sponsor bank / RBI (asset)', NULL
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'FUNDTRANSFER_SETTLEMENT');
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
SELECT 'I4C_RESTORATION_PAYABLE', 'I4C / MRM money restored from suspect accounts, payable to victims (sundry liability)', NULL
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'I4C_RESTORATION_PAYABLE');
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
SELECT 'I4C_SHADOW_CREDIT', 'Shadow credits to customers on reported unauthorised transactions (receivable)', NULL
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'I4C_SHADOW_CREDIT');
</sql>
</changeSet>
<changeSet author="fineract" id="0614-2-i4c-sop-columns">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_i4c_restoration" columnName="compliance_due_on"/></not>
</preConditions>
<addColumn tableName="m_i4c_restoration">
<!-- SOP process 1: the bank credits the victim within 15 days of the IO's notice / order -->
<column name="notice_received_on" type="DATE"/>
<column name="compliance_due_on" type="DATE"/>
<!-- SOP: the suspect may contest within 15 days of the refund -->
<column name="challenge_until" type="DATE"/>
<column name="contested" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="contested_on" type="DATE"/>
<column name="contest_remarks" type="VARCHAR(1000)"/>
<column name="payable_journal_ref" type="VARCHAR(64)"/>
<column name="debit_txn_id" type="BIGINT"/>
<!-- Victim bank (RBI customer-liability directions, 06.07.2017) -->
<column name="txn_date" type="DATE"/>
<column name="customer_reported_on" type="DATE"/>
<!-- ZERO_BANK_FAULT, ZERO_THIRD_PARTY, LIMITED, CUSTOMER_NEGLIGENCE -->
<column name="liability_category" type="VARCHAR(30)"/>
<column name="customer_liability" type="DECIMAL(19,2)"/>
<column name="shadow_credit_due_on" type="DATE"/>
<column name="resolution_due_on" type="DATE"/>
<column name="shadow_credit_amount" type="DECIMAL(19,2)"/>
<column name="shadow_credit_requested_by" type="VARCHAR(100)"/>
<column name="shadow_credit_requested_by_id" type="BIGINT"/>
<column name="shadow_credit_status" type="VARCHAR(20)"/>
<column name="shadow_credit_txn_id" type="BIGINT"/>
<column name="shadow_credit_journal_ref" type="VARCHAR(64)"/>
<column name="shadow_credited_by" type="VARCHAR(100)"/>
<column name="shadow_credited_at" type="TIMESTAMP"/>
<column name="regularised_amount" type="DECIMAL(19,2)"/>
<column name="regularise_journal_ref" type="VARCHAR(64)"/>
<column name="regularised_at" type="TIMESTAMP"/>
</addColumn>
<addColumn tableName="m_i4c_hold">
<!-- SOP 90-day rule: from the bank's grievance; the LEA is told 15 days before -->
<column name="lapse_on" type="DATE"/>
<column name="lea_notice_due_on" type="DATE"/>
<column name="lea_notice_sent_on" type="DATE"/>
<column name="lea_notice_ref" type="VARCHAR(200)"/>
<column name="extension_until" type="DATE"/>
<column name="extension_ref" type="VARCHAR(200)"/>
<column name="digital_suspended" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0614-3-i4c-risk-tables">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_channel_block"/></not>
</preConditions>
<!-- Digital channels suspended on a repeatedly reported account (enforced on CBS fund transfers). -->
<createTable tableName="m_i4c_channel_block">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="hold_id" type="BIGINT"/>
<column name="account_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
<!-- comma list of INTERNAL, IMPS, NEFT, RTGS, UPI, ATM, CARD, AEPS, NETBANKING -->
<column name="channels" type="VARCHAR(200)"><constraints nullable="false"/></column>
<column name="reason" type="VARCHAR(500)"/>
<column name="status" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="placed_by" type="VARCHAR(100)"/>
<column name="placed_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="lifted_by" type="VARCHAR(100)"/>
<column name="lifted_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_i4c_channel_block" indexName="idx_i4c_channel_block_account">
<column name="account_type"/>
<column name="account_id"/>
</createIndex>
<!-- Mule-account register: accounts / customers used to move fraud money. -->
<createTable tableName="m_i4c_mule_flag">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="client_id" type="BIGINT"/>
<column name="account_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
<!-- AUTO_REPEAT (reported in several complaints), OFFICER, LEA -->
<column name="source" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="reports_count" type="INT"/>
<column name="reason" type="VARCHAR(1000)"/>
<!-- SUSPECTED, CONFIRMED, CLEARED -->
<column name="status" type="VARCHAR(12)"><constraints nullable="false"/></column>
<column name="flagged_by" type="VARCHAR(100)"/>
<column name="flagged_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="reviewed_by" type="VARCHAR(100)"/>
<column name="reviewed_at" type="TIMESTAMP"/>
<column name="review_remarks" type="VARCHAR(1000)"/>
</createTable>
<createIndex tableName="m_i4c_mule_flag" indexName="idx_i4c_mule_flag_client">
<column name="client_id"/>
</createIndex>
<!-- Fraud reporting: police / LEA, RBI fraud monitoring return, FIU-IND suspicious transaction report. -->
<createTable tableName="m_i4c_fraud_report">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="hold_id" type="BIGINT"/>
<column name="mule_flag_id" type="BIGINT"/>
<column name="client_id" type="BIGINT"/>
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="acknowledgement_nos" type="VARCHAR(1000)"/>
<column name="amount" type="DECIMAL(19,2)"/>
<column name="classified_on" type="DATE"><constraints nullable="false"/></column>
<column name="fmr_due_on" type="DATE"/>
<column name="fmr_ref" type="VARCHAR(100)"/>
<column name="fmr_filed_on" type="DATE"/>
<column name="str_due_on" type="DATE"/>
<column name="str_ref" type="VARCHAR(100)"/>
<column name="str_filed_on" type="DATE"/>
<column name="police_ref" type="VARCHAR(200)"/>
<column name="police_reported_on" type="DATE"/>
<column name="remarks" type="VARCHAR(2000)"/>
<column name="status" type="VARCHAR(12)"><constraints nullable="false"/></column>
<column name="created_by" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="updated_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<!-- Fraud money that repaid a loan / dues here: the SOP asks for the amount to be held and linked accounts told. -->
<createTable tableName="m_i4c_linked_alert">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="hold_id" type="BIGINT"><constraints nullable="false"/></column>
<!-- LOAN_REPAYMENT, INTERNAL_TRANSFER -->
<column name="kind" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="target_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="target_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="target_no" type="VARCHAR(40)"/>
<column name="amount" type="DECIMAL(19,2)"><constraints nullable="false"/></column>
<column name="transfer_id" type="BIGINT"/>
<column name="moved_on" type="DATE"/>
<!-- OPEN, ACTIONED, DISMISSED -->
<column name="status" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="remarks" type="VARCHAR(1000)"/>
<column name="actioned_by" type="VARCHAR(100)"/>
<column name="actioned_at" type="TIMESTAMP"/>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_i4c_linked_alert" indexName="idx_i4c_linked_alert_hold">
<column name="hold_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0614-4-i4c-risk-permission">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'MANAGE_I4CRISK'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="MANAGE_I4CRISK"/>
<column name="entity_name" value="I4CRISK"/>
<column name="action_name" value="MANAGE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,117 @@
<?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.
-->
<!--
1. Nodal, pool and escrow accounts are not held on an I4C complaint (MHA SOP: the prospective hold is for first-layer
mule accounts, "not nodal, pool or escrow accounts"; RBI's Sep 2026 draft mule SOP excludes them too). The bank
lists them here; a complaint against one is answered "hold not placed" and traced through the merchant settlement.
2. Bank-initiated temporary debit holds on suspected money-mule transactions (RBI draft amendment to the KYC
Directions, 11.09.2026, Annex III; effective 01.04.2027 if finalised, banks may adopt earlier): hold at once,
tell the customer with reasons and an officer's contact, 20 days to explain, the bank decides within 10 days of the
explanation (or 30 days from the hold), refers to the police through NCRP if not satisfied, the police have 30
days to instruct (the hold lifts on day 31 otherwise), 60 days at most without an LEA instruction, grievances
resolved by the nodal officer within 30 days.
-->
<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.3.xsd">
<changeSet author="fineract" id="0615-1-i4c-hold-exclusion">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_hold_exclusion"/></not>
</preConditions>
<createTable tableName="m_i4c_hold_exclusion">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="account_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
<!-- NODAL, POOL, ESCROW -->
<column name="kind" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="reason" type="VARCHAR(500)"/>
<column name="status" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="added_by" type="VARCHAR(100)"/>
<column name="added_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="removed_by" type="VARCHAR(100)"/>
<column name="removed_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_i4c_hold_exclusion" indexName="idx_i4c_hold_exclusion_account">
<column name="account_type"/>
<column name="account_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0615-2-i4c-bank-hold">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_bank_hold"/></not>
</preConditions>
<createTable tableName="m_i4c_bank_hold">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="account_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="client_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<!-- TRANSACTION (amount of the flagged transaction held) or ACCOUNT (all debits stopped; last resort) -->
<column name="scope" type="VARCHAR(12)"><constraints nullable="false"/></column>
<!-- PROFILE_MISMATCH, LINKED_TO_REPORTED_ACCOUNT, OTHER -->
<column name="trigger_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="flagged_txn_ref" type="VARCHAR(100)"/>
<column name="flagged_amount" type="DECIMAL(19,2)"><constraints nullable="false"/></column>
<column name="hold_amount" type="DECIMAL(19,2)"/>
<column name="hold_txn_id" type="BIGINT"/>
<column name="reason" type="VARCHAR(1000)"><constraints nullable="false"/></column>
<!-- HELD, EXPLAINED, REFERRED, CONTINUED, LIFTED -->
<column name="status" type="VARCHAR(12)"><constraints nullable="false"/></column>
<column name="placed_by" type="VARCHAR(100)"/>
<column name="placed_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="customer_notified_at" type="TIMESTAMP"/>
<column name="notification_mode" type="VARCHAR(20)"/>
<column name="notification_ref" type="VARCHAR(200)"/>
<column name="explanation_due_on" type="DATE"><constraints nullable="false"/></column>
<column name="explanation_received_on" type="DATE"/>
<column name="explanation" type="VARCHAR(2000)"/>
<column name="decision_due_on" type="DATE"><constraints nullable="false"/></column>
<column name="decision" type="VARCHAR(10)"/>
<column name="decision_remarks" type="VARCHAR(1000)"/>
<column name="decided_by" type="VARCHAR(100)"/>
<column name="decided_at" type="TIMESTAMP"/>
<column name="referred_on" type="DATE"/>
<column name="ncrp_ref" type="VARCHAR(100)"/>
<column name="police_due_on" type="DATE"/>
<column name="police_instruction" type="VARCHAR(10)"/>
<column name="police_ref" type="VARCHAR(200)"/>
<column name="max_until" type="DATE"><constraints nullable="false"/></column>
<column name="lifted_at" type="TIMESTAMP"/>
<column name="lifted_by" type="VARCHAR(100)"/>
<column name="lift_reason" type="VARCHAR(500)"/>
<column name="grievance_raised_on" type="DATE"/>
<column name="grievance_due_on" type="DATE"/>
<column name="grievance_text" type="VARCHAR(2000)"/>
<column name="grievance_resolved_on" type="DATE"/>
<column name="grievance_resolution" type="VARCHAR(2000)"/>
</createTable>
<createIndex tableName="m_i4c_bank_hold" indexName="idx_i4c_bank_hold_account">
<column name="account_type"/>
<column name="account_id"/>
</createIndex>
</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.
-->
<!--
1. Customer protection in fraudulent electronic banking transactions: RBI (Urban Co-operative Banks - Responsible
Business Conduct) Third Amendment Directions, 2026 (RBI/2026-27/172, 24.06.2026), for transactions on or after
01.01.2027. They replace the 2017 "limiting liability" framework, which still governs earlier transactions:
- zero liability on UCB negligence, or a third-party breach reported within 5 calendar days;
- complaint resolved within 45 calendar days (60 cross-border);
- shadow reversal: a provisional credit the customer cannot use (held here by a lien on the credited amount);
- compensation for customer-negligence losses up to Rs 50,000: 85% of the net loss or Rs 25,000, whichever is
less, once in a lifetime, reported to the bank and to NCRP / 1930 within 5 calendar days; paid within 5
calendar days of the application; shared by RBI, the customer's bank and the beneficiary bank and claimed
from RBI quarterly; for frauds within one year of 01.01.2027.
2. RBI (Urban Co-operative Banks - Fraud Risk Management) Directions, 2026 (RBI/DoS/2026-27/439, 31.07.2026): before
a person or entity is classified as fraudulent, a show-cause notice with at least 21 days to respond and a
reasoned order; the fraud monitoring return within 14 days of the classification.
-->
<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.3.xsd">
<changeSet author="fineract" id="0616-1-i4c-liability-regime">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_i4c_restoration" columnName="liability_regime"/></not>
</preConditions>
<addColumn tableName="m_i4c_restoration">
<!-- RBI_2017 (transactions before 01.01.2027) or RBI_2026 -->
<column name="liability_regime" type="VARCHAR(10)"/>
<column name="liability_note" type="VARCHAR(500)"/>
<column name="cross_border" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="ncrp_reported_on" type="DATE"/>
<column name="gross_loss" type="DECIMAL(19,2)"/>
<column name="recovered_amount" type="DECIMAL(19,2)"/>
<column name="victim_client_id" type="BIGINT"/>
<column name="shadow_hold_txn_id" type="BIGINT"/>
<column name="shadow_finalised_at" type="TIMESTAMP"/>
<column name="comp_eligible" type="BOOLEAN"/>
<column name="comp_reason" type="VARCHAR(500)"/>
<column name="comp_amount" type="DECIMAL(19,2)"/>
<column name="comp_share_rbi" type="DECIMAL(19,2)"/>
<column name="comp_share_own" type="DECIMAL(19,2)"/>
<column name="comp_share_beneficiary" type="DECIMAL(19,2)"/>
<!-- APPLIED, PAID, REJECTED -->
<column name="comp_status" type="VARCHAR(10)"/>
<column name="comp_applied_on" type="DATE"/>
<column name="comp_due_on" type="DATE"/>
<column name="comp_applied_by_id" type="BIGINT"/>
<column name="comp_paid_on" type="DATE"/>
<column name="comp_txn_id" type="BIGINT"/>
<column name="comp_journal_ref" type="VARCHAR(64)"/>
</addColumn>
<sql>
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
SELECT 'I4C_COMPENSATION_RECEIVABLE', 'Fraud compensation paid, recoverable from RBI and the beneficiary bank (receivable)', NULL
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'I4C_COMPENSATION_RECEIVABLE');
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
SELECT 'I4C_COMPENSATION_EXPENSE', 'Fraud compensation borne by this bank (expense)', NULL
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'I4C_COMPENSATION_EXPENSE');
</sql>
</changeSet>
<changeSet author="fineract" id="0616-2-i4c-fraud-show-cause">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_i4c_fraud_report" columnName="show_cause_sent_on"/></not>
</preConditions>
<addColumn tableName="m_i4c_fraud_report">
<column name="show_cause_sent_on" type="DATE"/>
<column name="show_cause_due_on" type="DATE"/>
<column name="show_cause_response" type="VARCHAR(2000)"/>
<column name="show_cause_responded_on" type="DATE"/>
<column name="order_ref" type="VARCHAR(200)"/>
<column name="order_on" type="DATE"/>
</addColumn>
<!-- Classification now comes after the show-cause and the reasoned order. -->
<dropNotNullConstraint tableName="m_i4c_fraud_report" columnName="classified_on" columnDataType="DATE"/>
</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.
-->
<!--
When money left a held account for another bank, the response names that bank by its I4C Bank Code so CFCFRMS can
notify it next (MHA SOP 6(g), 6(j): the notices continue "till the complete money trail is established"). The seeded
master knows the IFSC prefix of only some banks, and co-operative banks often use a sponsor bank's IFSC series
(e.g. HDFC0C...), where the prefix names the wrong bank. This map lets the bank add a 4-letter prefix or a full
11-character IFSC against the right I4C Bank Code; a full IFSC wins over a prefix, which wins over the master.
-->
<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.3.xsd">
<changeSet author="fineract" id="0617-1-i4c-ifsc-map">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_ifsc_map"/></not>
</preConditions>
<createTable tableName="m_i4c_ifsc_map">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<!-- 4-letter IFSC prefix, or a full 11-character IFSC -->
<column name="ifsc_key" type="VARCHAR(11)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_i4c_ifsc_map_key"/></column>
<column name="bank_code" type="INT"><constraints nullable="false"/></column>
<column name="note" type="VARCHAR(300)"/>
<column name="added_by" type="VARCHAR(100)"/>
<column name="added_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,113 @@
/**
* 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.cfcfrms.i4c.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cCustomerLiability.Assessment;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cCustomerLiability.Compensation;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cCustomerLiability.Regime;
import org.junit.jupiter.api.Test;
/** RBI customer protection: the 2017 framework before 01.01.2027 and the 2026 directions (RBI/2026-27/172) after. */
class I4cCustomerLiabilityTest {
private static final LocalDate JAN_2027 = LocalDate.of(2027, 1, 4);
@Test
void theTransactionDateChoosesTheFramework() {
assertEquals(Regime.RBI_2017, I4cCustomerLiability.regimeFor(LocalDate.of(2026, 12, 31)));
assertEquals(Regime.RBI_2026, I4cCustomerLiability.regimeFor(LocalDate.of(2027, 1, 1)));
}
@Test
void under2026ZeroLiabilityNeedsAThirdPartyBreachReportedWithinFiveCalendarDays() {
final Assessment inTime = I4cCustomerLiability.assess("THIRD_PARTY_BREACH", JAN_2027, JAN_2027.plusDays(5), false, "SAVINGS");
assertTrue(inTime.zeroLiability());
assertNull(inTime.shadowReversalDue());
assertEquals(JAN_2027.plusDays(5).plusDays(45), inTime.resolutionDue());
final Assessment late = I4cCustomerLiability.assess("THIRD_PARTY_BREACH", JAN_2027, JAN_2027.plusDays(6), true, "SAVINGS");
assertFalse(late.zeroLiability());
assertEquals(JAN_2027.plusDays(6).plusDays(60), late.resolutionDue());
assertTrue(I4cCustomerLiability.assess("BANK_NEGLIGENCE", JAN_2027, JAN_2027.plusDays(40), false, "SAVINGS").zeroLiability());
}
@Test
void under2017ThreeWorkingDaysAreZeroAndSevenAreLimited() {
final LocalDate monday = LocalDate.of(2026, 10, 19);
assertTrue(I4cCustomerLiability.assess("THIRD_PARTY_BREACH", monday, monday.plusDays(3), false, "SAVINGS").zeroLiability());
final Assessment limited = I4cCustomerLiability.assess("THIRD_PARTY_BREACH", monday, monday.plusDays(8), false, "SAVINGS");
assertFalse(limited.zeroLiability());
assertEquals(new BigDecimal("10000"), limited.limitedCap());
assertEquals(LocalDate.of(2026, 11, 10), limited.shadowReversalDue());
}
@Test
void compensationIsEightyFivePerCentOfNetLossBelowTheCap() {
final Compensation c = I4cCustomerLiability.compensation("CUSTOMER_NEGLIGENCE", JAN_2027, JAN_2027.plusDays(2), JAN_2027.plusDays(1),
new BigDecimal("20000"), BigDecimal.ZERO, false, false);
assertTrue(c.eligible());
assertEquals(new BigDecimal("17000.00"), c.amount());
assertEquals(new BigDecimal("13000.00"), c.rbi());
assertEquals(new BigDecimal("2000.00"), c.ownBank());
assertEquals(new BigDecimal("2000.00"), c.beneficiaryBank());
}
@Test
void compensationIsCappedAtTwentyFiveThousandWithFixedShares() {
// The directions' illustration: Rs 40,000 lost, Rs 15,000 recovered -> 85% of Rs 25,000 = Rs 21,250.
final Compensation partial = I4cCustomerLiability.compensation("CUSTOMER_NEGLIGENCE", JAN_2027, JAN_2027, JAN_2027,
new BigDecimal("40000"), new BigDecimal("15000"), false, false);
assertEquals(new BigDecimal("21250.00"), partial.amount());
final Compensation capped = I4cCustomerLiability.compensation("CUSTOMER_NEGLIGENCE", JAN_2027, JAN_2027, JAN_2027,
new BigDecimal("45000"), BigDecimal.ZERO, false, false);
assertEquals(new BigDecimal("25000"), capped.amount());
assertEquals(new BigDecimal("19118"), capped.rbi());
assertEquals(new BigDecimal("2941"), capped.ownBank());
assertEquals(new BigDecimal("2941"), capped.beneficiaryBank());
}
@Test
void compensationHasConditions() {
final LocalDate before = LocalDate.of(2026, 12, 20);
assertFalse(I4cCustomerLiability.compensation("CUSTOMER_NEGLIGENCE", before, before, before, new BigDecimal("1000"), null, false, false)
.eligible());
assertFalse(I4cCustomerLiability.compensation("CUSTOMER_NEGLIGENCE", JAN_2027, JAN_2027.plusDays(6), JAN_2027, new BigDecimal("1000"),
null, false, false).eligible());
assertFalse(I4cCustomerLiability.compensation("CUSTOMER_NEGLIGENCE", JAN_2027, JAN_2027, JAN_2027, new BigDecimal("50001"), null,
false, false).eligible());
assertFalse(I4cCustomerLiability.compensation("CUSTOMER_NEGLIGENCE", JAN_2027, JAN_2027, JAN_2027, new BigDecimal("1000"), null,
false, true).eligible());
assertFalse(I4cCustomerLiability.compensation("BANK_NEGLIGENCE", JAN_2027, JAN_2027, JAN_2027, new BigDecimal("1000"), null, false,
false).eligible());
assertFalse(I4cCustomerLiability.compensation("CUSTOMER_NEGLIGENCE", LocalDate.of(2028, 1, 2), LocalDate.of(2028, 1, 2),
LocalDate.of(2028, 1, 2), new BigDecimal("1000"), null, false, false).eligible());
}
@Test
void showCauseGivesTwentyOneDays() {
assertEquals(21, I4cRiskService.SHOW_CAUSE_DAYS);
assertEquals(14, I4cRiskService.FMR_DAYS);
}
}
@@ -100,6 +100,45 @@ class I4cGrmMrmTest {
assertEquals("a_b.pdf", I4cDocumentService.safeName("a<b.pdf"));
}
@Test
void shadowCreditIsDueTenWorkingDaysAfterTheReport() {
// Reported on Friday 23 Oct 2026: ten working days later, skipping two weekends, is Friday 6 Nov 2026.
assertEquals(java.time.LocalDate.of(2026, 11, 6), I4cRestorationService.addWorkingDays(java.time.LocalDate.of(2026, 10, 23), 10));
}
@Test
void aHoldLapsesOnlyWithoutAnExtensionInForce() {
final java.time.LocalDate today = java.time.LocalDate.of(2026, 10, 23);
final Map<String, Object> h = new HashMap<>();
h.put("review_due_on", java.sql.Date.valueOf(today.plusDays(10)));
assertFalse(I4cRiskService.lapsed(h, today));
h.put("lapse_on", java.sql.Date.valueOf(today.minusDays(1)));
assertTrue(I4cRiskService.lapsed(h, today));
h.put("extension_until", java.sql.Date.valueOf(today.plusDays(30)));
assertFalse(I4cRiskService.lapsed(h, today));
h.put("extension_until", "2026-10-01");
assertTrue(I4cRiskService.lapsed(h, today));
}
@Test
void grmDeadlinesFollowTheSop() {
// GRM manual / SOP 9.1: the bank raises the grievance within 7 days, the IO decides within 15.
assertEquals(java.time.Duration.ofDays(7), I4cGrievanceService.BANK_WINDOW);
assertEquals(java.time.Duration.ofDays(15), I4cGrievanceService.IO_WINDOW);
assertEquals(java.time.Duration.ofDays(15), I4cGrievanceService.REVIEW_WINDOW);
}
@Test
void bankHoldTimelineFollowsTheRbiDraft() {
assertEquals(20, I4cBankHoldService.EXPLANATION_DAYS);
assertEquals(10, I4cBankHoldService.DECISION_DAYS_AFTER_EXPLANATION);
assertEquals(30, I4cBankHoldService.DECISION_DAYS_WITHOUT_EXPLANATION);
assertEquals(30, I4cBankHoldService.POLICE_DAYS);
assertEquals(60, I4cBankHoldService.MAX_DAYS);
assertEquals(0, new BigDecimal("1000").compareTo(I4cBankHoldService.MIN_FLAGGED));
assertEquals("XXXXXXXXXXX0004", I4cBankHoldService.mask("002001010100004"));
}
@Test
void eachGrmStageNamesWhoDecides() {
assertEquals("the IO", I4cGrievanceService.expectedLevel("IO_INVESTIGATION"));
@@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
@@ -37,6 +38,7 @@ import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
@@ -60,6 +62,7 @@ class I4cHoldServiceTest {
private static final AccountRef SAVINGS = new AccountRef(I4cAccountGateway.SAVINGS, 7L, "000100", 3L, 1L, 300, true);
private static final AccountRef CLOSED = new AccountRef(I4cAccountGateway.SAVINGS, 8L, "000200", 3L, 1L, 600, false);
private static final String FIND_BY_INCIDENT = "SELECT * FROM m_i4c_hold WHERE incident_id = ?";
private static final String SAME_INCIDENT = "JOIN m_i4c_incident cur";
@Mock
private JdbcTemplate jdbcTemplate;
@@ -71,6 +74,8 @@ class I4cHoldServiceTest {
private AppUser maker;
@Mock
private I4cRestorationExecutor restorationExecutor;
@Mock
private I4cRiskService riskService;
private I4cHoldService service;
@@ -80,8 +85,9 @@ class I4cHoldServiceTest {
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil
.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.now(ZoneId.systemDefault()))));
service = new I4cHoldService(jdbcTemplate, gateway, new I4cProperties(), transactionManager, restorationExecutor);
service = new I4cHoldService(jdbcTemplate, gateway, new I4cProperties(), transactionManager, restorationExecutor, riskService);
lenient().when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(99L);
lenient().when(jdbcTemplate.queryForList(contains(SAME_INCIDENT), any(Object[].class))).thenReturn(List.of());
}
@AfterEach
@@ -109,10 +115,65 @@ class I4cHoldServiceTest {
when(gateway.withdrawable(SAVINGS)).thenReturn(new BigDecimal("600"));
when(gateway.placeHold(eq(SAVINGS), eq(new BigDecimal("600")), anyString())).thenReturn(556L);
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 12L, "316", 2, SAVINGS, new BigDecimal("2500"));
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 12L, "316", 1, SAVINGS, new BigDecimal("2500"));
assertEquals(new BigDecimal("600"), outcome.amountHeld());
assertEquals(new BigDecimal("1900"), outcome.shortfall());
assertEquals(new BigDecimal("1900"), outcome.gap());
}
@Test
void placesNoProspectiveHoldBeyondTheFirstLayer() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 16L)).thenReturn(List.of());
when(gateway.withdrawable(SAVINGS)).thenReturn(new BigDecimal("600"));
when(gateway.placeHold(eq(SAVINGS), eq(new BigDecimal("600")), anyString())).thenReturn(557L);
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 16L, "316", 2, SAVINGS, new BigDecimal("2500"));
assertEquals(new BigDecimal("600"), outcome.amountHeld());
assertEquals(0, outcome.shortfall().signum());
assertEquals(new BigDecimal("1900"), outcome.gap());
assertTrue(outcome.noProspectiveReason().contains("layer 2"));
}
@Test
void holdsTheDisputedAmountOnANodalAccountButNoProspectiveHold() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 17L)).thenReturn(List.of());
when(gateway.exclusion(SAVINGS.type(), SAVINGS.id())).thenReturn(Optional.of("NODAL"));
when(gateway.withdrawable(SAVINGS)).thenReturn(new BigDecimal("600"));
when(gateway.placeHold(eq(SAVINGS), eq(new BigDecimal("600")), anyString())).thenReturn(558L);
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 17L, "316", 1, SAVINGS, new BigDecimal("2500"));
assertTrue(outcome.holdable());
assertEquals(new BigDecimal("600"), outcome.amountHeld());
assertEquals(0, outcome.shortfall().signum());
assertEquals("nodal account", outcome.noProspectiveReason());
}
@Test
void aRepeatedPushOfTheSameTransactionDoesNotHoldAgain() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 18L)).thenReturn(List.of());
when(jdbcTemplate.queryForList(contains(SAME_INCIDENT), any(Object[].class))).thenReturn(List.of(Map.of("id", 41L, "amount_held",
new BigDecimal("600"), "shortfall", new BigDecimal("1900"), "disputed_amount", new BigDecimal("2500"), "status",
I4cHoldService.ACTIVE)));
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 18L, "316", 1, SAVINGS, new BigDecimal("2500"));
assertEquals(41L, outcome.holdId());
assertEquals(new BigDecimal("1900"), outcome.gap());
verify(gateway, never()).placeHold(any(), any(), anyString());
}
@Test
void aFreezeOrDigitalSuspensionNeedsTheLeaDirection() {
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", 45L))
.thenReturn(List.of(Map.of("id", 45L, "status", "ACTIVE", "account_type", "SAVINGS", "debit_frozen", false)));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(45L, "FREEZE", "LEA_DIRECTION", " ", null, null, null, maker));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(45L, "SUSPEND_DIGITAL", "LEA_DIRECTION", null, null, null, null, maker));
}
@Test
@@ -141,7 +202,8 @@ class I4cHoldServiceTest {
@Test
void neverHoldsTwiceForTheSameIncident() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 15L)).thenReturn(List.of(
Map.of("id", 40L, "amount_held", new BigDecimal("500"), "shortfall", new BigDecimal("100"), "status", I4cHoldService.ACTIVE)));
Map.of("id", 40L, "amount_held", new BigDecimal("500"), "shortfall", new BigDecimal("100"), "disputed_amount",
new BigDecimal("600"), "status", I4cHoldService.ACTIVE)));
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 15L, "316", 1, SAVINGS, new BigDecimal("600"));
@@ -190,11 +252,22 @@ class I4cHoldServiceTest {
}
@Test
void freezeIsRefusedOnCurrentAccounts() {
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", 41L))
.thenReturn(List.of(Map.of("id", 41L, "status", "ACTIVE", "account_type", "CURRENT", "debit_frozen", false)));
void unfreezeAndDigitalRestoreNeedSomethingToLift() {
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", 41L)).thenReturn(List.of(Map.of("id", 41L, "status",
"ACTIVE", "account_type", "CURRENT", "debit_frozen", false, "digital_suspended", false)));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(41L, "FREEZE", "LEA_DIRECTION", "X/1", null, null, null, maker));
() -> service.requestAction(41L, "UNFREEZE", "LEA_DIRECTION", "X/1", null, null, null, maker));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(41L, "RESTORE_DIGITAL", "GRM_DECISION", "X/1", null, null, null, maker));
}
@Test
void aNinetyDayReleaseWithoutAnOrderNeedsTheHoldToHaveLapsed() {
final java.sql.Date future = java.sql.Date.valueOf(LocalDate.now(ZoneId.systemDefault()).plusDays(30));
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", 44L)).thenReturn(List.of(Map.of("id", 44L, "status",
"ACTIVE", "account_type", "SAVINGS", "debit_frozen", false, "amount_held", new BigDecimal("100"), "review_due_on", future)));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(44L, "RELEASE", "REVIEW_90_DAYS", null, null, null, null, maker));
}
@Test
@@ -0,0 +1,200 @@
/**
* 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.fundtransfer.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.account.service.AccountBookDebitService;
import org.apache.fineract.portfolio.clearing.service.ClearingGlAccountResolverService;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.useradministration.domain.AppUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
/** The books of an outward transfer: park, settle and reverse, in GL mode and in the legacy suspense-account mode. */
@ExtendWith(MockitoExtension.class)
class FundTransferLedgerServiceTest {
private static final long SUSPENSE = 64L;
private static final long SETTLEMENT = 11L;
private static final long CONTROL = 5L;
private static final long PAYABLE = 111L;
@Mock
private SuspenseGlService legacy;
@Mock
private ClearingGlAccountResolverService glResolver;
@Mock
private AccountingProcessorHelper accounting;
@Mock
private AccountBookDebitService bookEntries;
@Mock
private PlatformSecurityContext context;
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private AppUser user;
private FundTransferLedgerService ledger;
@BeforeEach
void setUp() {
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null));
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil
.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.now(ZoneId.systemDefault()))));
ledger = new FundTransferLedgerService(legacy, glResolver, accounting, bookEntries, context, jdbcTemplate);
lenient().when(context.authenticatedUser()).thenReturn(user);
lenient().when(user.getId()).thenReturn(1L);
lenient().when(jdbcTemplate.queryForList(anyString(), eq(7L))).thenReturn(List.of(Map.of("office_id", 1L)));
lenient().when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq(7L))).thenReturn(List.of("INR"));
lenient().when(glResolver.resolveControlGlAccountId("SAVINGS", 7L)).thenReturn(CONTROL);
}
@AfterEach
void tearDown() {
ThreadLocalContextUtil.reset();
}
private static FundTransfer transfer() {
final FundTransfer t = new FundTransfer();
t.setTxnRef("FTTEST");
t.setPayerAccountId(7L);
t.setAmount(new BigDecimal("2000.00"));
return t;
}
@Test
void withoutTheSuspenseGlTheLegacyAccountIsUsed() {
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.OUTWARD_SUSPENSE)).thenReturn(null);
final FundTransfer t = transfer();
when(legacy.parkInSuspense(t)).thenReturn(99L);
ledger.park(t);
assertEquals(FundTransferLedgerService.MODE_ACCOUNT, t.getParkMode());
assertEquals(99L, t.getInternalTxnId());
verify(accounting, never()).createGlToGlJournalEntries(anyLong(), anyString(), anyLong(), anyLong(), any(), anyString(), any(), any());
}
@Test
void inGlModeThePayerIsDebitedToOutwardSuspenseAndSettledOnSuccess() {
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.OUTWARD_SUSPENSE)).thenReturn(SUSPENSE);
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.SETTLEMENT)).thenReturn(SETTLEMENT);
when(bookEntries.debitSavings(eq(7L), any(), eq(1L), any(), eq(1L))).thenReturn(500L);
final FundTransfer t = transfer();
ledger.park(t);
assertEquals(FundTransferLedgerService.MODE_GL, t.getParkMode());
verify(accounting).createGlToGlJournalEntries(eq(1L), eq("INR"), eq(CONTROL), eq(SUSPENSE), eq(7L), eq("FTTEST-P"), any(),
eq(new BigDecimal("2000.00")));
ledger.settle(t);
assertEquals("FTTEST-S", t.getSettlementJournalRef());
verify(accounting).createGlToGlJournalEntries(eq(1L), eq("INR"), eq(SUSPENSE), eq(SETTLEMENT), eq(7L), eq("FTTEST-S"), any(),
eq(new BigDecimal("2000.00")));
}
@Test
void aFailureBeforeSettlementCreditsThePayerBackFromSuspense() {
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.OUTWARD_SUSPENSE)).thenReturn(SUSPENSE);
final FundTransfer t = transfer();
t.setParkMode(FundTransferLedgerService.MODE_GL);
ledger.reverse(t);
verify(bookEntries).creditSavings(eq(7L), eq(new BigDecimal("2000.00")), eq(1L), any(), eq(1L));
verify(accounting).createGlToGlJournalEntries(eq(1L), eq("INR"), eq(SUSPENSE), eq(CONTROL), eq(7L), eq("FTTEST-R"), any(),
eq(new BigDecimal("2000.00")));
assertEquals("FTTEST-R", t.getReversalJournalRef());
}
@Test
void aRecallAfterSettlementComesBackFromTheSettlementAccount() {
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.SETTLEMENT)).thenReturn(SETTLEMENT);
final FundTransfer t = transfer();
t.setParkMode(FundTransferLedgerService.MODE_GL);
t.setSettlementJournalRef("FTTEST-S");
ledger.reverse(t);
verify(accounting).createGlToGlJournalEntries(eq(1L), eq("INR"), eq(SETTLEMENT), eq(CONTROL), eq(7L), eq("FTTEST-R"), any(), any());
}
@Test
void aGlSourcedPaymentNeverTouchesACustomerAccount() {
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.OUTWARD_SUSPENSE)).thenReturn(SUSPENSE);
final FundTransfer t = transfer();
t.setPayerAccountId(null);
t.setSourceGlAccountId(PAYABLE);
t.setSourceOfficeId(2L);
ledger.park(t);
assertEquals(FundTransferLedgerService.MODE_GL_SOURCE, t.getParkMode());
verify(accounting).createGlToGlJournalEntries(eq(2L), eq("INR"), eq(PAYABLE), eq(SUSPENSE), eq(null), eq("FTTEST-P"), any(), any());
ledger.reverse(t);
verify(accounting).createGlToGlJournalEntries(eq(2L), eq("INR"), eq(SUSPENSE), eq(PAYABLE), eq(null), eq("FTTEST-R"), any(), any());
verify(bookEntries, never()).debitSavings(any(), any(), any(), any(), any());
verify(bookEntries, never()).creditSavings(any(), any(), any(), any(), any());
}
@Test
void aGlSourcedPaymentNeedsTheSuspenseGl() {
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.OUTWARD_SUSPENSE)).thenReturn(null);
final FundTransfer t = transfer();
t.setSourceGlAccountId(PAYABLE);
t.setSourceOfficeId(2L);
assertThrows(GeneralPlatformDomainRuleException.class, () -> ledger.park(t));
}
@Test
void settlementWaitsWhenTheSettlementGlIsNotMapped() {
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.OUTWARD_SUSPENSE)).thenReturn(SUSPENSE);
when(glResolver.resolveClearingGlAccountId(FundTransferLedgerService.SETTLEMENT)).thenReturn(null);
final FundTransfer t = transfer();
t.setParkMode(FundTransferLedgerService.MODE_GL);
ledger.settle(t);
assertNull(t.getSettlementJournalRef());
}
}