From e0cbb80ca442c0a87e8f6260dc82bbb253909c2a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 26 Oct 2026 11:22:47 +0530 Subject: [PATCH] feat(cfcfrms): I4C SOP layer chain, outward transfer ledger, bank holds and RBI 2026 liability - 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 --- CFCFRMS_I4C_INTEGRATION.md | 95 ++- .../service/AccountingProcessorHelper.java | 15 + .../service/AccountBookDebitService.java | 85 ++- .../cfcfrms/i4c/api/I4cCasesApiResource.java | 340 +++++++++++ .../cfcfrms/i4c/config/I4cProperties.java | 2 - .../cfcfrms/i4c/jobs/I4cJobsConfig.java | 14 + .../i4c/service/I4cAccountGateway.java | 38 +- .../i4c/service/I4cAdminReadService.java | 237 +++++++- .../cfcfrms/i4c/service/I4cBankDirectory.java | 9 + .../i4c/service/I4cBankHoldService.java | 327 +++++++++++ .../i4c/service/I4cComplaintProcessor.java | 19 +- .../i4c/service/I4cCustomerLiability.java | 185 ++++++ .../i4c/service/I4cGrievanceService.java | 43 +- .../cfcfrms/i4c/service/I4cHoldService.java | 94 ++- .../i4c/service/I4cIntimationService.java | 25 + .../cfcfrms/i4c/service/I4cMoneyMover.java | 179 +++++- .../i4c/service/I4cRestorationExecutor.java | 62 +- .../i4c/service/I4cRestorationService.java | 234 +++++++- .../cfcfrms/i4c/service/I4cRiskService.java | 539 ++++++++++++++++++ .../i4c/service/I4cTransactionTracer.java | 13 +- .../simulator/I4cSimulatorApiResource.java | 18 +- ...WritePlatformServiceJpaRepositoryImpl.java | 3 + .../client/service/KycRestrictionGuard.java | 25 + .../fundtransfer/domain/FundTransfer.java | 28 +- .../service/ChannelBlockGuard.java | 54 ++ .../service/FundTransferLedgerService.java | 204 +++++++ .../FundTransferWritePlatformService.java | 9 + .../FundTransferWritePlatformServiceImpl.java | 68 ++- .../ImpsDeemedReconciliationService.java | 6 +- .../service/NeftBatchProcessorService.java | 8 +- .../src/main/resources/application.properties | 6 +- .../tenant/final-changelog-tenant.xml | 4 + .../parts/0614_i4c_sop_and_outward_ledger.xml | 236 ++++++++ .../0615_i4c_exclusions_and_bank_holds.xml | 117 ++++ ..._i4c_rbi_2026_liability_and_show_cause.xml | 97 ++++ .../parts/0617_i4c_ifsc_bank_code_map.xml | 47 ++ .../i4c/service/I4cCustomerLiabilityTest.java | 113 ++++ .../cfcfrms/i4c/service/I4cGrmMrmTest.java | 39 ++ .../i4c/service/I4cHoldServiceTest.java | 87 ++- .../FundTransferLedgerServiceTest.java | 200 +++++++ 40 files changed, 3771 insertions(+), 153 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cBankHoldService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cCustomerLiability.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRiskService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ChannelBlockGuard.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferLedgerService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0614_i4c_sop_and_outward_ledger.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0615_i4c_exclusions_and_bank_holds.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0616_i4c_rbi_2026_liability_and_show_cause.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0617_i4c_ifsc_bank_code_map.xml create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cCustomerLiabilityTest.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferLedgerServiceTest.java diff --git a/CFCFRMS_I4C_INTEGRATION.md b/CFCFRMS_I4C_INTEGRATION.md index 720913783..e27ef8585 100644 --- a/CFCFRMS_I4C_INTEGRATION.md +++ b/CFCFRMS_I4C_INTEGRATION.md @@ -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 token bank ──POST (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-`), 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-`), 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. --- diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index a591b75d7..b9e44d832 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java index f47c5b129..0073f987a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountBookDebitService.java @@ -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> 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> 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 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> rows = jdbcTemplate.queryForList( diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/api/I4cCasesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/api/I4cCasesApiResource.java index 60d3e4ec4..f369e097e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/api/I4cCasesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/api/I4cCasesApiResource.java @@ -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 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 page = readService.fraudReports(I4cListQuery.of(Map.of("limit", List.of("500")), 500)); + @SuppressWarnings("unchecked") + final List> rows = (List>) page.get("pageItems"); + for (final Map 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 fields(final String body) { + final Map out = new LinkedHashMap<>(); + for (final Map.Entry 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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/config/I4cProperties.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/config/I4cProperties.java index a33e702e5..426aa9322 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/config/I4cProperties.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/config/I4cProperties.java @@ -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"); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/jobs/I4cJobsConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/jobs/I4cJobsConfig.java index 44124ee43..3d503f529 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/jobs/I4cJobsConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/jobs/I4cJobsConfig.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cAccountGateway.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cAccountGateway.java index 40b6052ff..b0d423be6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cAccountGateway.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cAccountGateway.java @@ -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 exclusion(final String type, final Long id) { + final List 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 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cAdminReadService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cAdminReadService.java index e06a63acd..6a392ba9d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cAdminReadService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cAdminReadService.java @@ -41,6 +41,7 @@ public class I4cAdminReadService { private final JdbcTemplate jdbcTemplate; private final I4cProperties properties; + private final I4cMoneyMover mover; public Map 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 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 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. + *

+ * 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 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 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 out) { + final List> items = (List>) out.get("pageItems"); + if (items == null || items.isEmpty()) { + return; + } + final List 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 layers = new java.util.HashMap<>(); + for (final Map 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 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 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 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 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 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 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 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 ifscMap() { + final Map 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 exclusions(final I4cListQuery q) { + final StringBuilder sql = new StringBuilder("SELECT * FROM m_i4c_hold_exclusion x WHERE 1=1"); + final List 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 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 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 channelBlocks(final I4cListQuery q) { + final StringBuilder sql = new StringBuilder("SELECT * FROM m_i4c_channel_block x WHERE 1=1"); + final List 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> statement(final Long holdId, final java.time.LocalDate from, final java.time.LocalDate to) { final List> hold = rows("SELECT account_type, account_id FROM m_i4c_hold WHERE id = ?", holdId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cBankDirectory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cBankDirectory.java index 740612461..24cddbcfe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cBankDirectory.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cBankDirectory.java @@ -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> 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> 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)); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cBankHoldService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cBankHoldService.java new file mode 100644 index 000000000..0f3866eb1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cBankHoldService.java @@ -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): + * + *
+ * 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
+ * 
+ * + * 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 TRIGGERS = Set.of("PROFILE_MISMATCH", "LINKED_TO_REPORTED_ACCOUNT", "OTHER"); + public static final Set 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 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 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 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> 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 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 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 load(final Long id, final String... allowed) { + final List> 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cComplaintProcessor.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cComplaintProcessor.java index 69a122f5c..e5a6628ff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cComplaintProcessor.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cComplaintProcessor.java @@ -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"); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cCustomerLiability.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cCustomerLiability.java new file mode 100644 index 000000000..d0fa19ffe --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cCustomerLiability.java @@ -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. + * + *
    + *
  • RBI_2026 - (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.
  • + *
  • RBI_2017 - "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.
  • + *
+ */ +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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cGrievanceService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cGrievanceService.java index 64eae4808..ed17b33fb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cGrievanceService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cGrievanceService.java @@ -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: * *
- * 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
  * 
@@ -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 REQUIRED_DOCUMENTS = List.of("KYC", "APPLICATION", "PHOTO", "BANK_STATEMENT"); public static final Set 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 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 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 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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cHoldService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cHoldService.java index 1f4e7093c..df8a22b05 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cHoldService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cHoldService.java @@ -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 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 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> existing = findByIncident(incidentId); + final Optional> 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 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 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> findSameIncident(final Long incidentId, final String ack, final AccountRef account, final int layer) { + final List> 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> 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 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 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(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cIntimationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cIntimationService.java index 16932ba9a..7d817bb8e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cIntimationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cIntimationService.java @@ -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 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cMoneyMover.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cMoneyMover.java index f14e8850c..271e361fd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cMoneyMover.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cMoneyMover.java @@ -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. + * + *
    + *
  • Victim's account in this bank: an account-to-account transfer (Dr suspect's control, Cr victim's control).
  • + *
  • 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.
  • + *
+ * 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 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(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 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRestorationExecutor.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRestorationExecutor.java index 0db74d1b4..b0fe1ca13 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRestorationExecutor.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRestorationExecutor.java @@ -43,11 +43,13 @@ import org.springframework.transaction.support.TransactionTemplate; *
    *
  • 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.
  • - *
  • 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.
  • + *
  • 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.
  • *
+ * 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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRestorationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRestorationService.java index 89102b8f6..f23f479f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRestorationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRestorationService.java @@ -68,6 +68,10 @@ public class I4cRestorationService { public static final Set BANK_ACTIONS = Set.of("COMPLIED", "NOT_COMPLIED", "REFER_BACK_LEA", "REFER_BACK_VICTIM_BANK", "CONSENSUS"); public static final Set 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 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 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 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 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 f, final AppUser user) { + final Map 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 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 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> 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 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 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 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 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 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 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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRiskService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRiskService.java new file mode 100644 index 000000000..e0bf0658c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cRiskService.java @@ -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. + * + *
    + *
  • Digital channels 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.
  • + *
  • Mule-account register: an account reported in two or more complaints is flagged SUSPECTED automatically; + * an officer confirms or clears it.
  • + *
  • Fraud reporting: police / LEA, RBI fraud monitoring return (default 14 days from classification) and the + * FIU-IND suspicious transaction report (default 7 days), with due dates.
  • + *
  • Linked repayments: 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.
  • + *
  • 90-day lapse: counted from the bank's grievance; the LEA is told 15 days before, and an extension recorded.
  • + *
+ */ +@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 MULE_REVIEW = Set.of("CONFIRMED", "CLEARED"); + public static final Set 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 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 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 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 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 h = hold(holdId); + final List 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 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 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 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 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 fraudReport(final Long id, final String status) { + final List> 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 f, final AppUser user) { + final List> 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 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 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 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 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 hold(final Long holdId) { + final List> 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 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 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 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 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); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cTransactionTracer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cTransactionTracer.java index 1dc2325cd..72cbcf9bd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cTransactionTracer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cTransactionTracer.java @@ -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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/simulator/I4cSimulatorApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/simulator/I4cSimulatorApiResource.java index 34630ca3c..c8b219082 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/simulator/I4cSimulatorApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cfcfrms/i4c/simulator/I4cSimulatorApiResource.java @@ -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; * *
    *
  • {@code auth/token}, {@code bank/response} - what the bank calls when its outbound base URL points here.
  • - *
  • {@code push} - builds a complaint from a few fields and feeds it in exactly as an I4C push would arrive.
  • + *
  • {@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}.
  • *
  • {@code received} - the responses the bank has sent, newest first.
  • *
*/ @@ -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(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index bfc03c613..b43808c0e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java index 912af3e3e..86aaa4fee 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java @@ -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> 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/FundTransfer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/FundTransfer.java index 9cebef790..a357b51c0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/FundTransfer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/domain/FundTransfer.java @@ -66,10 +66,10 @@ public class FundTransfer extends AbstractAuditableWithUTCDateTimeCustom { @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 { @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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ChannelBlockGuard.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ChannelBlockGuard.java new file mode 100644 index 000000000..ef4d540f3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ChannelBlockGuard.java @@ -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> 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 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); + } + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferLedgerService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferLedgerService.java new file mode 100644 index 000000000..26b7d4ab7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferLedgerService.java @@ -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. + * + *

+ * 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: + * + *

+ * 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
+ * 
+ * + * 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> 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 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(); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformService.java index 4253f339f..c2a4d11c3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformService.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformServiceImpl.java index d2ad2ba15..9831eb899 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferWritePlatformServiceImpl.java @@ -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); + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ImpsDeemedReconciliationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ImpsDeemedReconciliationService.java index 409454e46..985257c81 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ImpsDeemedReconciliationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/ImpsDeemedReconciliationService.java @@ -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", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/NeftBatchProcessorService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/NeftBatchProcessorService.java index e5a19060b..9442b7ab0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/NeftBatchProcessorService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/fundtransfer/service/NeftBatchProcessorService.java @@ -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)); diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index f0fe9bd49..14d8fda0f 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -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 diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 0fbb9f5bb..28dc86fb3 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -182,4 +182,8 @@ + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0614_i4c_sop_and_outward_ledger.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0614_i4c_sop_and_outward_ledger.xml new file mode 100644 index 000000000..c74efd72b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0614_i4c_sop_and_outward_ledger.xml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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'); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MANAGE_I4CRISK' + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0615_i4c_exclusions_and_bank_holds.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0615_i4c_exclusions_and_bank_holds.xml new file mode 100644 index 000000000..0dd891af3 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0615_i4c_exclusions_and_bank_holds.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0616_i4c_rbi_2026_liability_and_show_cause.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0616_i4c_rbi_2026_liability_and_show_cause.xml new file mode 100644 index 000000000..dfd512bf5 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0616_i4c_rbi_2026_liability_and_show_cause.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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'); + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0617_i4c_ifsc_bank_code_map.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0617_i4c_ifsc_bank_code_map.xml new file mode 100644 index 000000000..1b60b62b1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0617_i4c_ifsc_bank_code_map.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cCustomerLiabilityTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cCustomerLiabilityTest.java new file mode 100644 index 000000000..a48d2e624 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cCustomerLiabilityTest.java @@ -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); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cGrmMrmTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cGrmMrmTest.java index 635f5bb5d..16c9b247a 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cGrmMrmTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cGrmMrmTest.java @@ -100,6 +100,45 @@ class I4cGrmMrmTest { assertEquals("a_b.pdf", I4cDocumentService.safeName("a 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")); diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cHoldServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cHoldServiceTest.java index 9c7c71287..6d2054d67 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cHoldServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/service/I4cHoldServiceTest.java @@ -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 diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferLedgerServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferLedgerServiceTest.java new file mode 100644 index 000000000..276d8fba0 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/fundtransfer/service/FundTransferLedgerServiceTest.java @@ -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()); + } +}