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
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:
+85
-10
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
+84
-1
@@ -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(
|
||||
|
||||
+340
@@ -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();
|
||||
|
||||
-2
@@ -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");
|
||||
}
|
||||
|
||||
+14
@@ -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);
|
||||
|
||||
+27
-11
@@ -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;
|
||||
|
||||
+231
-6
@@ -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);
|
||||
|
||||
+9
@@ -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));
|
||||
|
||||
+327
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
-6
@@ -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");
|
||||
}
|
||||
|
||||
+185
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
-12
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+81
-13
@@ -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();
|
||||
}
|
||||
|
||||
+25
@@ -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);
|
||||
|
||||
+151
-28
@@ -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;
|
||||
}
|
||||
|
||||
+36
-26
@@ -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,
|
||||
|
||||
+232
-2
@@ -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,
|
||||
|
||||
+539
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+6
-7
@@ -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();
|
||||
|
||||
+17
-1
@@ -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();
|
||||
}
|
||||
|
||||
+3
@@ -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();
|
||||
|
||||
+25
@@ -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;
|
||||
|
||||
+26
-2
@@ -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;
|
||||
}
|
||||
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+58
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
+5
-3
@@ -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>
|
||||
|
||||
+236
@@ -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>
|
||||
+117
@@ -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>
|
||||
+97
@@ -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>
|
||||
+47
@@ -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>
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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"));
|
||||
|
||||
+80
-7
@@ -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
|
||||
|
||||
+200
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user