Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ffe270931 | ||
|
|
f792f09ab1 | ||
|
|
e0cbb80ca4 | ||
|
|
f016bde385 |
@@ -0,0 +1,427 @@
|
||||
# CFCFRMS: I4C-MHA API Integration (Banks ↔ I4C 2.0)
|
||||
|
||||
This is the runbook for the bank's real-time integration with the NCRP-CFCFRMS platform of I4C (Ministry of Home Affairs), built to the **I4C-MHA "Banks API Integration Specification 2.0"**. It covers what the integration does, how to configure and test it, and what an officer does with it day to day.
|
||||
|
||||
The work is required by RBI letter **CO.DOS.KYC.No.S6675/11-01-006/2025-26 (05 Dec 2025)**. It asks banks, including Primary UCBs, to:
|
||||
- integrate by API for real-time action on cyber-fraud complaints;
|
||||
- cover every system and delivery channel;
|
||||
- periodically review the integration's performance to minimise failures.
|
||||
|
||||
The older CFCFRMS demo (`/v1/cfcfrms/*`, `/v1/simulator/cfcfrms/*` and the Angular mock interceptor) is untouched. This integration lives beside it under `/v1/i4c/*`.
|
||||
|
||||
---
|
||||
|
||||
## 1. What happens to a complaint
|
||||
|
||||
```
|
||||
I4C ──POST /auth/token──────────────► bank checks username/password (plain or CryptoJS-encrypted) → access_token
|
||||
I4C ──POST /complaints (Bearer)─────► bank checks token + source IP → validates → stores → ack {00, job_id}
|
||||
│ (after commit, on the I4C worker pool)
|
||||
▼
|
||||
for each incident (own transaction):
|
||||
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,
|
||||
report where the rest went (next bank, by I4C bank code),
|
||||
layer 1 only: keep the rest as a prospective hold
|
||||
▼
|
||||
bank ──POST <I4C auth>───────────────► I4C token
|
||||
bank ──POST <I4C response> (Bearer)──► I4C section 6 message: acknowledgement_no, job_id, transactions[]
|
||||
(outbox: retried with backoff until delivered)
|
||||
```
|
||||
|
||||
### The chain: Bank A → Bank B → Bank C (MHA SOP 02.01.2026, para 6(g), 6(j), 9.1)
|
||||
|
||||
- The victim complains on NCRP / 1930. CFCFRMS sends the complaint to the **victim's bank (A, layer 0)** and to the bank that received the money (**B, layer 1**), each with its own layer number. A reports which debit it was and where it went; B holds what is left.
|
||||
- If B's customer had already sent part of it to **Bank C**, B reports a *Money Transfer To* entry naming C by its **I4C bank code**. CFCFRMS then sends C its own complaint as **layer 2**, and so on "till the complete money trail is established" (SOP 6(j)).
|
||||
- This CBS plays every role. It reads the layer in each incident:
|
||||
- **Layer 0:** it traces the victim's debit.
|
||||
- **Layer 1** (a first-layer account, SOP definition (x)): it holds the disputed amount, reports the onward transfers for what is gone, and keeps a **prospective hold** for the gap. The prospective hold tops up from later credits (SOP 9.1(i)).
|
||||
- **Layer 2 and later:** it holds what is there and reports onward. It places **no prospective hold**, because SOP 9.1(i) limits that to first-layer accounts.
|
||||
- **The next bank must be named by its code.** The seeded master knows only some IFSC prefixes, and co-operative banks often use a sponsor bank's IFSC series. Bank Master > *IFSC to I4C Bank Code map* (migration 0617, endpoints `GET/POST /v1/i4c/cases/ifsc-map`) maps a prefix or a full IFSC to the right code. It also lists the IFSCs already reported with code 0, and the dashboard counts them.
|
||||
- **Money that moved within this bank** (to another savings / current account here, or to a loan) is raised as a linked alert by the Hold Review job, so the officer can act before CFCFRMS sends the next-layer complaint back to us.
|
||||
- **Repeat pushes:** the same reported transaction (ack, account, layer, RRN, amount) arriving again in a new request finds the earlier hold. It is never held twice.
|
||||
- **Freeze and digital suspension** need the LEA's direction (order / notice reference, SOP 9.1(i), S.106 BNSS). Without it, only the disputed amount is held.
|
||||
- The hold detail shows the customer's **other accounts** here, to report as related accounts (SOP 9.1(v)). They are not held automatically.
|
||||
|
||||
### Response types produced
|
||||
|
||||
| txn_type_id | txn_type | When |
|
||||
|---|---|---|
|
||||
| 1 | Transaction Put on Hold | Money held on the account here. A 0.00 entry with remarks means nothing could be held yet; the amount is prospective. |
|
||||
| 3 | Money Transfer To | The money went on by outward IMPS/NEFT/RTGS (payee IFSC, account, UTR and I4C bank code), or to another account in this bank. |
|
||||
| 14 | Withdrawal through Cheque | Withdrawn at the branch against a cheque (clerk register or CTS clearing). A branch cash withdrawal above Rs 500 is also reported here, with remarks saying so (see §8). |
|
||||
| 17 | Others Less than equal to 500 | An untraced debit of Rs 500 or less. |
|
||||
| status_code 01 | Record not found | The account does not exist in this CBS. |
|
||||
| status_code 02 | Invalid RRN | The account exists, but no debit/credit of that amount near that date. |
|
||||
| 70010 | (in remarks) | The incident could not be processed after 5 attempts. It is reported so I4C is not left waiting, and is handled manually. |
|
||||
|
||||
Types 5 (ATM), 6 (AEPS) and 11 (POS) are built to the specification's field lists, but are not produced today, because this CBS has no ATM, AEPS or POS channel. A channel that records those debits only has to classify them as `ATM_WITHDRAWAL` / `AEPS_WITHDRAWAL` / `POS_WITHDRAWAL` in `I4cTransactionTracer`, with the ATM / merchant details.
|
||||
|
||||
The Money Transfer To entry carries the payee reference under **both** `rrn` (the section 6 table) and `rrn_transaction_id` (the section 6 sample), with the same value.
|
||||
|
||||
### Rules applied to holds
|
||||
|
||||
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** (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.
|
||||
- **Release** always goes through maker-checker and records the reason and order:
|
||||
- IO order;
|
||||
- GRM decision (IO / DGRO / SGRO);
|
||||
- MRM restoration;
|
||||
- court order;
|
||||
- LEA direction;
|
||||
- 90-day review;
|
||||
- wrong account.
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints
|
||||
|
||||
### Called by I4C (outside the Fineract login; see `SecurityConfig`)
|
||||
|
||||
| Method | Path | Spec section |
|
||||
|---|---|---|
|
||||
| POST | `/fineract-provider/api/v1/i4c/auth/token` | 2: returns `{"access_token":"…","token_type":"Bearer","expires_in":900}` or 401 `{"message":"You are not authorized to access"}`. Credentials may come as JSON `username`/`password` or `client_id`/`client_secret`, as a form body, or as an `Authorization: Basic` header. |
|
||||
| POST | `/fineract-provider/api/v1/i4c/complaints` | 4 and 5: returns `{"meta":{"response_code":"00","response_message":"Success"},"data":{"acknowledgement_no":"…","job_id":"BANKS-…"}}`, or code `11` "Structure validation failed" (HTTP 400) |
|
||||
|
||||
I4C sends no tenant header. These paths are served by the tenant in `fineract.i4c.tenant-identifier`.
|
||||
|
||||
Other refusals:
|
||||
- 503 with code `70004` while the integration is disabled;
|
||||
- 403 from an IP not on the allowlist;
|
||||
- 413 (`70005`) above 1 MB;
|
||||
- 429 (`70006`) above 600 calls/minute from one IP;
|
||||
- 400 (`60015`) when an encrypted envelope cannot be decrypted.
|
||||
|
||||
**How a push may authenticate** (section 2 names "Basic Auth / Auth 2.0" and credentials "in every request"). `fineract.i4c.inbound.auth-modes` lists the allowed ones; every one checks the same credentials and the same IP allowlist:
|
||||
|
||||
| Mode | What I4C sends |
|
||||
|---|---|
|
||||
| `TOKEN` | `Authorization: Bearer <access_token>` from `/auth/token` |
|
||||
| `BASIC` | `Authorization: Basic base64(username:password)` |
|
||||
| `BODY` | `username` / `password` (or `client_id` / `client_secret`) inside the complaint JSON. They are removed from the message before it is stored. |
|
||||
|
||||
Each credential may be plain, plain Base64 (the specification's text), or CryptoJS-encrypted (its samples).
|
||||
|
||||
**Refused pushes.** With `ack.code-style=SECTION5` the ack is always `11` "Structure validation failed". With `SECTION7` it carries the specific section 7 code: `40001` invalid account number, `40004` invalid amount, `40005` invalid date (also a `transaction_date` more than `ack.max-future-days` ahead).
|
||||
|
||||
**A repeat push** with the same acknowledgement number and the same incidents is never processed twice. With `ack.duplicate-mode=SAME_JOB` it gets `00` and the original job_id. With `CODE_50003` it gets HTTP 409 and `50003` "Duplicate Transaction", still with the original job_id.
|
||||
|
||||
**Separate ack call.** The ack is always the reply to the push. If I4C also wants it as a call of its own (section 5 is titled "Ack Request Message from BANKS to I4C-MHA"), set `outbound.ack-path`. The ack is then queued with the request and delivered before the response.
|
||||
|
||||
### Officer API (ordinary login): `/v1/i4c/admin/*`
|
||||
|
||||
| Path | Permission |
|
||||
|---|---|
|
||||
| `GET dashboard`, `requests`, `requests/{id}`, `holds`, `holds/{id}`, `hold-actions`, `outbox`, `api-log`, `api-log/{id}`, `bank-master`, `configuration` | `READ_I4C` |
|
||||
| `POST holds/{id}/actions` (`RELEASE` with an optional `amount` for a partial release, `UNFREEZE_AND_RELEASE`, `FREEZE`, `UNFREEZE`) | `REQUESTACTION_I4CHOLD` |
|
||||
| `POST hold-actions/{id}/approve`, `.../reject` | `APPROVEACTION_I4CHOLD` (never your own request) |
|
||||
| `POST holds/{id}/intimation` (record by hand), `POST holds/{id}/intimation/send` (send now on the automatic channels) | `INTIMATE_I4CHOLD` |
|
||||
| `GET intimation-channels` | `READ_I4C` |
|
||||
| `PUT intimation-channels/{channel}`, `POST intimation-channels/{channel}/test` | `CONFIGURE_I4C` |
|
||||
| `POST outbox/{id}/retry`, `requests/{id}/reprocess` | `RETRY_I4C` |
|
||||
|
||||
Every list (`requests`, `holds`, `hold-actions`, `outbox`, `api-log`, `bank-master`) is paged: it takes `offset` and `limit` (default 20, at most 500) and returns `{totalFilteredRecords, pageItems}`. Each list also takes `fromDate` / `toDate` (yyyy-MM-dd, both days included; not for `bank-master`) and its own filters, which are listed in the Swagger summary of each endpoint.
|
||||
|
||||
### GRM / MRM API (ordinary login): `/v1/i4c/cases/*`
|
||||
|
||||
| Path | Permission |
|
||||
|---|---|
|
||||
| `GET grievances`, `grievances/{id}`, `restorations`, `restorations/{id}`, `pro-rata`, `mrm-balance-report`, `mrm-balance-report.csv`, `holds/{id}/statement.csv`, `documents`, `documents/{id}/content` | `READ_I4C` |
|
||||
| `POST holds/{id}/grievances`, `grievances/{id}/edd`, `…/submit`, `…/nodal`, `…/events`, `…/investigation`, `…/decision`, `…/escalate`, `…/withdraw` | `MANAGE_I4CGRIEVANCE` |
|
||||
| `POST grievances/{id}/action` (raise the account change an order needs) | `MANAGE_I4CGRIEVANCE` + `REQUESTACTION_I4CHOLD` |
|
||||
| `POST holds/{id}/restorations` (assess), `PUT restorations/{id}/request`, `POST restorations/{id}/bank-action`, `…/mrm-updated`, `victim-cases`, `restorations/{id}/receipt`, `…/victim-status` | `MANAGE_I4CRESTORATION` (bank action COMPLIED also needs `REQUESTACTION_I4CHOLD`) |
|
||||
| `POST restorations/{id}/retry-outward` | `APPROVEACTION_I4CHOLD` |
|
||||
| `POST documents` | `MANAGE_I4CGRIEVANCE` / `MANAGE_I4CRESTORATION` / `REQUESTACTION_I4CHOLD` by owner |
|
||||
|
||||
In the UI: **CFCFRMS → I4C Live**. Its tabs are Dashboard, Complaints from I4C, Holds Register, Approvals, GRM Grievances, MRM Restorations, MRM Balance Report, Responses & API Log, Bank Code Master, and Intimation Settings. A hold's **GRM / MRM & Documents** tab opens a grievance or an MRM assessment, downloads the account statement and holds the IO / court orders.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration (`application.properties`, all values from the environment)
|
||||
|
||||
| Key / env var | Meaning |
|
||||
|---|---|
|
||||
| `fineract.i4c.enabled` / `FINERACT_I4C_ENABLED` | Master switch. Default `false`. |
|
||||
| `fineract.i4c.tenant-identifier` / `FINERACT_I4C_TENANT` | Tenant that serves I4C's calls. |
|
||||
| `fineract.i4c.bank-code` / `FINERACT_I4C_BANK_CODE` | This bank's code in the I4C Bank Code Master (`root_bankid`). |
|
||||
| `fineract.i4c.bank-name` | Name reported for internal transfers. Defaults to the master's name for the code. |
|
||||
| `…inbound.username` / `FINERACT_I4C_INBOUND_USERNAME` | Username the bank issues to I4C. |
|
||||
| `…inbound.password-hash` / `FINERACT_I4C_INBOUND_PASSWORD_HASH` | **bcrypt** hash of that password (production). |
|
||||
| `…inbound.password` / `FINERACT_I4C_INBOUND_PASSWORD` | Plain password. Used only when no hash is set (UAT). |
|
||||
| `…inbound.token-ttl-seconds` | Token lifetime. Default 900. |
|
||||
| `…inbound.allowed-ips` / `FINERACT_I4C_ALLOWED_IPS` | Comma-separated I4C source IPs. **Blank allows any IP, which is for UAT only.** |
|
||||
| `…inbound.trust-forwarded-for` | Set `true` only behind a reverse proxy that sets `X-Forwarded-For`. |
|
||||
| `…inbound.auth-modes` / `FINERACT_I4C_AUTH_MODES` | `TOKEN,BASIC,BODY` (default). List only the forms I4C uses. |
|
||||
| `…inbound.client-name` / `FINERACT_I4C_CLIENT_NAME` | Client name registered with I4C. The client ID and secret are the inbound username and password. |
|
||||
| `…crypto.mode` / `FINERACT_I4C_CRYPTO_MODE` | `FIELDS` (username/password encrypted, as in the spec), `ENVELOPE` (whole body as `{"data":"…"}`), or `NONE`. |
|
||||
| `…crypto.passphrase` / `FINERACT_I4C_CRYPTO_PASSPHRASE` | The "encryption logic" passphrase I4C shares. |
|
||||
| `…outbound.base-url` / `FINERACT_I4C_OUTBOUND_BASE_URL` | I4C's base URL (from I4C). `internal-simulator` routes to the local simulator. |
|
||||
| `…outbound.auth-path`, `…outbound.response-path` | Paths under the base URL. Defaults `/auth/token`, `/bank/response`. **Confirm with I4C.** |
|
||||
| `…outbound.ack-path` / `FINERACT_I4C_OUTBOUND_ACK_PATH` | Path for a separate ack call. Blank (default) = none. |
|
||||
| `…outbound.username`, `…outbound.password` | Credentials I4C issues to the bank. |
|
||||
| `…outbound.timeout-ms`, `…outbound.max-attempts` | Defaults 15000 ms and 8 attempts (backoff 30 s → 1 h). |
|
||||
| `…match.date-window-days` | ± days for amount/date matching. Default 2. |
|
||||
| `…ack.code-style` / `FINERACT_I4C_ACK_CODE_STYLE` | `SECTION5` (always 11) or `SECTION7` (40001 / 40004 / 40005). Default `SECTION5`. |
|
||||
| `…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.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.** |
|
||||
|
||||
To generate the bcrypt hash: `htpasswd -bnBC 10 "" 'the-password' | tr -d ':\n'` (or any bcrypt tool). Paste the result as `FINERACT_I4C_INBOUND_PASSWORD_HASH`.
|
||||
|
||||
### Jobs (Scheduler Jobs screen)
|
||||
- **I4C Dispatch**, every minute:
|
||||
- processes complaints the immediate worker did not finish;
|
||||
- sends responses waiting in the outbox;
|
||||
- purges expired tokens.
|
||||
- **I4C Hold Review**, every 15 minutes: tops up prospective holds, retries automatic intimation, and settles outward MRM restorations the fund-transfer module has finished.
|
||||
- Both do nothing while the integration is disabled.
|
||||
|
||||
### Database
|
||||
- **Migration `0610_cfcfrms_i4c_integration.xml`:**
|
||||
- tables `m_i4c_access_token`, `m_i4c_request`, `m_i4c_incident`, `m_i4c_finding`, `m_i4c_hold`, `m_i4c_hold_txn`, `m_i4c_hold_action_request`, `m_i4c_outbox`, `m_i4c_api_log`, `m_i4c_bank_master`;
|
||||
- five permissions;
|
||||
- two jobs.
|
||||
- **Migration `0611_i4c_bank_master_seed.xml`:**
|
||||
- the specification's Bank Code Master (491 entries);
|
||||
- the IFSC prefixes of 57 banks whose prefix is certain.
|
||||
- **Migration `0612_i4c_intimation_channels.xml`:**
|
||||
- `m_i4c_intimation_channel` (EMAIL switched on with a default subject and message; SMS and WHATSAPP switched off);
|
||||
- `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**).
|
||||
2. **EDD:** KYC re-verified, the customer's explanation of the credit, risk, and whether the bank supports the request.
|
||||
3. Raise it on GRM with the KYC, application, photograph and bank statement (attach the same here; the statement downloads from the hold), then record the **GR id**. Stage NODAL_REVIEW.
|
||||
4. The bank's GRM nodal officer approves (stage WITH_IO, **7 days**) or rejects.
|
||||
5. The IO decides; if the IO investigates (notice, VC, Samanvaya) record it (**+15 days**). VC requests and information sought are noted in the history.
|
||||
6. Decision as GRM shows it: *remove put on hold*, *unfreeze, hold continues*, *unfreeze and remove hold*, *keep a smaller amount* (partial), *rejected*, or *seek information*. An allowing decision needs the order number and the signed order copy, and starts a **48-hour** compliance clock.
|
||||
7. **Raise the account change**: the request goes to a second officer on Approvals; approval changes the account and closes the grievance.
|
||||
8. Rejected: escalate to the District GRO, then the State GRO (**15 days** each); after the State GRO the customer may go to court.
|
||||
|
||||
### MRM: money restoration
|
||||
**Beneficiary bank** (the money is held here):
|
||||
1. **Assess** each complaint (hold → GRM / MRM → *Assess for MRM*): *possible by attribution*, *possible pro-rata* (the calculator splits what is held on an account by disputed amount), or *not possible* (already refunded / no balance / other) with the reason. Enter the same on MRM; the **MRM Balance Report** page gives every held account's balances for MRM's *Update Account Balance* (CSV).
|
||||
2. When the victim's request comes from the IO, record the MR id, the refund account (IFSC blank = this bank), the IO, and the papers: a **court order**, or the IO's **BNSS 106 notice** with the victim's **indemnity bond**, plus the **FIR** above Rs 50,000. Attach their copies.
|
||||
3. **Bank action:** *Compliance* checks the papers and raises a RESTORE request; *not complied* and *refer back to LEA* need the bank statement; *refer back to victim bank* and *consensus* are recorded for MRM.
|
||||
4. A second officer approves the RESTORE on Approvals. In the same transaction the amount is released from hold and leaves the suspect account: straight to the victim's account if it is here (RESTORED at once, transaction id `CBS-<id>`), otherwise 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).
|
||||
|
||||
### Automatic customer intimation
|
||||
- **Where it is set:** CFCFRMS → I4C Live → **Intimation Settings**. Each channel (Email, SMS, WhatsApp) has an on/off switch, an order, and an editable message. Placeholders such as `{customerName}`, `{amountHeld}`, `{ackNo}` and `{reviewDate}` are filled per hold. Email has a "Send test email" box.
|
||||
- **What happens:** right after the response for a complaint goes to I4C, each new hold with money held is sent to the switched-on channels in order. The first one that reaches the customer marks the hold intimated (mode, "Sent automatically to …", user `system`).
|
||||
- **When it does not reach the customer** (no email on file, mail server down): the attempt is logged as FAILED or SKIPPED_NO_DESTINATION, and the Hold Review job retries up to 3 times. After that, or when no channel is switched on, the hold stays "not yet told". An officer then calls the customer (or writes, or meets them) and records it on the hold's **Customer Intimation** tab.
|
||||
- **Today:** only Email is in use. It goes through the CBS mail settings (`spring.mail.*`), the same ones the KYC notices use. SMS and WhatsApp use the CBS notification gateways and should be switched on only once those gateways are live.
|
||||
- Phone, letter and in-person notices can never be sent by the system. They are always recorded by hand.
|
||||
|
||||
---
|
||||
|
||||
## 4. Onboarding checklist
|
||||
|
||||
1. **Nodal officer / SPOC** registered with I4C. I4C emails them:
|
||||
- user ids and passwords (both directions);
|
||||
- the encryption logic / passphrase;
|
||||
- its public IPs;
|
||||
- UAT URLs.
|
||||
2. **Give I4C what it needs from the bank:**
|
||||
- the endpoint base: `https://<bank-host>/fineract-provider/api/v1/i4c`;
|
||||
- the bank's public IP;
|
||||
- the client name, client id and client secret it asks for (the inbound username/password above).
|
||||
3. **Connectivity.** The C-DOT *AWS Client VPN* SOP (IAM Identity Center SSO + MFA) is for **people**: developers and officers testing against UAT. A server cannot run it unattended. For the CBS server, ask C-DOT/I4C for IP whitelisting of the bank's egress IP, or a site-to-site tunnel. Open the firewall both ways:
|
||||
- inbound from I4C's IPs to the CBS on 443;
|
||||
- outbound from the CBS to I4C.
|
||||
4. **Set the environment variables** in §3, with `fineract.i4c.enabled=true`, and restart. Check **I4C Live → Dashboard → Setup still needed**: it lists anything missing.
|
||||
5. **Grant permissions:**
|
||||
- `READ_I4C` to the fraud cell and branch managers;
|
||||
- `REQUESTACTION_I4CHOLD` to makers;
|
||||
- `APPROVEACTION_I4CHOLD` to checkers (different people);
|
||||
- `INTIMATE_I4CHOLD` to branch staff;
|
||||
- `RETRY_I4C` to IT operations.
|
||||
6. **Set the branch IFSCs** in Branch Profile (`m_office_profile.ifsc_code`). They are reported as `ifsc_code` / `root_ifsc_code`.
|
||||
7. **Test in UAT** with I4C (§5), then switch to production URLs and credentials.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing without I4C (local / UAT)
|
||||
|
||||
Set:
|
||||
```
|
||||
FINERACT_I4C_ENABLED=true
|
||||
FINERACT_I4C_SIMULATOR_ENABLED=true
|
||||
FINERACT_I4C_OUTBOUND_BASE_URL=internal-simulator
|
||||
FINERACT_I4C_INBOUND_USERNAME=i4c-test
|
||||
FINERACT_I4C_INBOUND_PASSWORD=i4c-test-pass
|
||||
FINERACT_I4C_BANK_CODE=<your code>
|
||||
```
|
||||
|
||||
Then, with a funded active savings account `000000012345`:
|
||||
|
||||
```bash
|
||||
H='https://localhost:8443/fineract-provider/api/v1'
|
||||
|
||||
# 1. Simulate a layer-1 push (fraud money credited to this account)
|
||||
curl -k -X POST "$H/i4c-sim/push" -H 'Content-Type: application/json' \
|
||||
-d '{"accountNumber":"000000012345","amount":"2500.00","disputedAmount":"2500.00","rrn":"612345678901","transactionDate":"2026-10-20","layer":"1"}'
|
||||
|
||||
# 2. Or go through the real inbound API exactly as I4C would
|
||||
TOKEN=$(curl -sk -X POST "$H/i4c/auth/token" -H 'Content-Type: application/json' \
|
||||
-d '{"username":"i4c-test","password":"i4c-test-pass"}' | sed 's/.*"access_token":"\([^"]*\)".*/\1/')
|
||||
curl -k -X POST "$H/i4c/complaints" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d @complaint.json
|
||||
|
||||
# 3. See what the bank sent "I4C"
|
||||
curl -k -X POST "$H/i4c-sim/received"
|
||||
```
|
||||
|
||||
What to check:
|
||||
- The ack is `00` with a `job_id`.
|
||||
- The account shows an *Amount Hold* transaction, and its available balance dropped by the held amount.
|
||||
- A withdrawal above the remaining balance is refused.
|
||||
- **I4C Live → Complaints** shows the complaint as PROCESSED, with the response SENT.
|
||||
- **Holds Register** shows the hold. Raise a release as one user. Approving it as the same user is refused; approving it as a second user releases the amount on the account.
|
||||
|
||||
The backend unit tests are under `fineract-provider/src/test/java/org/apache/fineract/portfolio/cfcfrms/i4c/`:
|
||||
```
|
||||
./gradlew :fineract-provider:test --tests "org.apache.fineract.portfolio.cfcfrms.i4c.*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Daily operations
|
||||
|
||||
- **Dashboard.** Watch these figures:
|
||||
- *Customer intimation overdue*;
|
||||
- *Holds due for 90-day review*;
|
||||
- *Release / freeze awaiting approval*;
|
||||
- *Responses failed*.
|
||||
|
||||
The API performance table (calls, failures, average and max ms in each direction, average complaint-to-response time) is the RBI para 4 periodic review. Export it monthly for the Board / IT committee.
|
||||
- **Holds Register:**
|
||||
- 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 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;
|
||||
- open any call to see the masked request and response.
|
||||
|
||||
---
|
||||
|
||||
## 7. Security notes
|
||||
|
||||
- I4C's token is 32 random bytes. Only its SHA-256 hash is stored. It expires after `token-ttl-seconds`.
|
||||
- Every inbound call checks the token **and** the source IP allowlist.
|
||||
- Credentials, tokens and PANs are masked in the API log and in the stored payload.
|
||||
- The inbound password should be supplied as a bcrypt hash. All secrets come from environment variables; none are committed defaults.
|
||||
- Holds placed automatically are attributed to the CBS **system** user. Releases and freezes are attributed to the approving officer, with the maker recorded.
|
||||
- The simulator endpoints answer 404 unless `fineract.i4c.simulator.enabled=true`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Known limits of this CBS (stated honestly)
|
||||
|
||||
- **No RRN/UTR on account transactions.**
|
||||
- A reported credit is matched by RRN only where the CBS holds one: outward fund transfers (`m_fund_transfer.utr/txn_ref`), or an RRN quoted in a transaction note or transfer description.
|
||||
- Otherwise it is matched by **account, exact amount and date ± window**. The match method is shown per incident.
|
||||
- Inbound IMPS is a stub that posts no credit.
|
||||
- **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-account transfer legs** are not linked transaction-to-transaction, so onward tracing from a current account is limited to counter withdrawals.
|
||||
- **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** 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.
|
||||
|
||||
---
|
||||
|
||||
## 9. Questions to settle with I4C / C-DOT
|
||||
|
||||
1. Exact URL paths for the bank → I4C token, response and (if any) ack calls, and the token lifetime.
|
||||
2. Is the section 5 ack the synchronous HTTP response to the push, or also a separate call? Both are supported; set `outbound.ack-path` for the second.
|
||||
3. Encryption: only username/password (assumed, `FIELDS`), or the whole payload? Is it the CryptoJS/OpenSSL passphrase format (AES-256-CBC, EVP_BytesToKey-MD5) as the samples suggest?
|
||||
4. Which code set does the ack use, `00`/`11` (section 5) or the section 7 codes (`40001`…)? Both are supported (`ack.code-style`). And does a repeat push get `00` or `50003` (`ack.duplicate-mode`)? Which auth form will I4C use: token, Basic, or credentials in the message (`inbound.auth-modes`)?
|
||||
5. The specification's samples have typos (`mode_of _payment`, `acknowledment_no`, `approval_code.`). Which spelling will I4C actually send? The parser accepts all of them.
|
||||
6. The date format for `transaction_datetime` (`yyyy-MM-dd HH:mm:ss` assumed).
|
||||
7. How to report branch cash withdrawals and debits of unknown channel.
|
||||
8. For layer ≥ 1, is `payer_account_number` the account at the receiving bank (assumed)?
|
||||
9. Production IP whitelisting or site-to-site connectivity in place of the user VPN.
|
||||
+1
-15
@@ -169,26 +169,12 @@ public class CommandSourceService {
|
||||
|| "RECURRINGDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())
|
||||
|| isDdsEntity(commandSource.getEntityName())
|
||||
|| isInvestmentEntity(commandSource.getEntityName())) {
|
||||
|| isDdsEntity(commandSource.getEntityName())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Investment module's maker-checker entities. Deals, settlements and their vouchers are entered by one person and
|
||||
* approved by another, so a super user's investment work goes to a checker as well. Unlike DDS, a super user may still
|
||||
* check an entry they made themselves. Daily rates are not here: they are entered without approval.
|
||||
*/
|
||||
public static boolean isInvestmentEntity(final String entityName) {
|
||||
return entityName != null && switch (entityName.toUpperCase()) {
|
||||
case "INVESTMENTPARTY", "INVESTMENTSCRIPT", "INVESTMENTPURCHASE", "INVESTMENTSALE", "INVESTMENTFD", "INVESTMENTTRANSFER",
|
||||
"INVESTMENTINTEREST", "INVESTMENTVALUATION" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The DDS module's maker-checker entities. A super user's DDS work goes to a checker like anyone
|
||||
* else's: DDS moves customers' money, and every such move is to be approved by a second person.
|
||||
|
||||
-216
@@ -95,222 +95,6 @@ public class CommandWrapperBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentParty() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTPARTY";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/parties";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateInvestmentParty(final Long partyId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "INVESTMENTPARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/investment/parties/" + partyId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteInvestmentParty(final Long partyId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "INVESTMENTPARTY";
|
||||
this.entityId = partyId;
|
||||
this.href = "/investment/parties/" + partyId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentScript() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTSCRIPT";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/scripts";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder updateInvestmentScript(final Long scriptId) {
|
||||
this.actionName = "UPDATE";
|
||||
this.entityName = "INVESTMENTSCRIPT";
|
||||
this.entityId = scriptId;
|
||||
this.href = "/investment/scripts/" + scriptId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentPurchase() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTPURCHASE";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/purchases";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteInvestmentPurchase(final Long purchaseId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "INVESTMENTPURCHASE";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder settleInvestmentPurchase(final Long purchaseId) {
|
||||
this.actionName = "SETTLE";
|
||||
this.entityName = "INVESTMENTPURCHASE";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId + "/settle";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentSale() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTSALE";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/sales";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder settleInvestmentSale(final Long saleId) {
|
||||
this.actionName = "SETTLE";
|
||||
this.entityName = "INVESTMENTSALE";
|
||||
this.entityId = saleId;
|
||||
this.href = "/investment/sales/" + saleId + "/settle";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteInvestmentSale(final Long saleId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "INVESTMENTSALE";
|
||||
this.entityId = saleId;
|
||||
this.href = "/investment/sales/" + saleId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentFd() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTFD";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/fds";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder matureInvestmentFd(final Long fdId) {
|
||||
this.actionName = "MATURE";
|
||||
this.entityName = "INVESTMENTFD";
|
||||
this.entityId = fdId;
|
||||
this.href = "/investment/fds/" + fdId + "/mature";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder prematureInvestmentFd(final Long fdId) {
|
||||
this.actionName = "PREMATURE";
|
||||
this.entityName = "INVESTMENTFD";
|
||||
this.entityId = fdId;
|
||||
this.href = "/investment/fds/" + fdId + "/premature";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder renewInvestmentFd(final Long fdId) {
|
||||
this.actionName = "RENEW";
|
||||
this.entityName = "INVESTMENTFD";
|
||||
this.entityId = fdId;
|
||||
this.href = "/investment/fds/" + fdId + "/renew";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder postInvestmentFdInterest(final Long fdId) {
|
||||
this.actionName = "POSTINTEREST";
|
||||
this.entityName = "INVESTMENTFD";
|
||||
this.entityId = fdId;
|
||||
this.href = "/investment/fds/" + fdId + "/interest";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder setInvestmentDepositBase() {
|
||||
this.actionName = "SETBASE";
|
||||
this.entityName = "INVESTMENTFD";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/fds/deposit-base";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder amortiseInvestmentPremium() {
|
||||
this.actionName = "AMORTISE";
|
||||
this.entityName = "INVESTMENTSALE";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/amortisation";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder writeOffInvestmentPurchase(final Long purchaseId) {
|
||||
this.actionName = "WRITEOFF";
|
||||
this.entityName = "INVESTMENTSALE";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId + "/write-off";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder redeemInvestmentPurchase(final Long purchaseId) {
|
||||
this.actionName = "REDEEM";
|
||||
this.entityName = "INVESTMENTSALE";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId + "/redeem";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder switchInvestmentFund(final Long purchaseId) {
|
||||
this.actionName = "SWITCH";
|
||||
this.entityName = "INVESTMENTSALE";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId + "/switch";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder dividendInvestmentPurchase(final Long purchaseId) {
|
||||
this.actionName = "DIVIDEND";
|
||||
this.entityName = "INVESTMENTPURCHASE";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId + "/dividends";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentValuation() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTVALUATION";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/valuations";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder transferInvestmentSecurity(final Long purchaseId) {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTTRANSFER";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId + "/transfer";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder postInvestmentInterest(final Long purchaseId) {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTINTEREST";
|
||||
this.entityId = purchaseId;
|
||||
this.href = "/investment/purchases/" + purchaseId + "/interest-postings";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createInvestmentDailyRate() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "INVESTMENTDAILYRATE";
|
||||
this.entityId = null;
|
||||
this.href = "/investment/daily-rates";
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder deleteInvestmentDailyRate(final Long rateId) {
|
||||
this.actionName = "DELETE";
|
||||
this.entityName = "INVESTMENTDAILYRATE";
|
||||
this.entityId = rateId;
|
||||
this.href = "/investment/daily-rates/" + rateId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CommandWrapperBuilder createChequeStockLot() {
|
||||
this.actionName = "CREATE";
|
||||
this.entityName = "CHEQUESTOCKLOT";
|
||||
|
||||
+2
@@ -81,6 +81,8 @@ public enum JobName {
|
||||
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
|
||||
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
|
||||
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
|
||||
I4C_DISPATCH("I4C Dispatch"), //
|
||||
I4C_HOLD_REVIEW("I4C Hold Review"), //
|
||||
; //
|
||||
|
||||
private final String name;
|
||||
|
||||
+15
@@ -902,6 +902,21 @@ public class AccountingProcessorHelper {
|
||||
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-line entry between two GL accounts given by id, for a book movement that is not itself a customer
|
||||
* transaction: an outward transfer parked in suspense and later settled with the sponsor bank, an I4C restoration
|
||||
* payable. {@code savingsId} links both lines to the savings account involved, if any. The transaction id should
|
||||
* be non-numeric (e.g. "FT…-P") so it is stored as given and groups the two lines.
|
||||
*/
|
||||
public void createGlToGlJournalEntries(final Long officeId, final String currencyCode, final Long debitGlId, final Long creditGlId,
|
||||
final Long savingsId, final String transactionId, final LocalDate transactionDate, final BigDecimal amount) {
|
||||
final Office office = this.officeRepository.getReferenceById(officeId);
|
||||
createDebitJournalEntryForSavings(office, currencyCode, this.glAccountRepository.getReferenceById(debitGlId), savingsId,
|
||||
transactionId, transactionDate, amount);
|
||||
createCreditJournalEntryForSavings(office, currencyCode, this.glAccountRepository.getReferenceById(creditGlId), savingsId,
|
||||
transactionId, transactionDate, amount);
|
||||
}
|
||||
|
||||
/** Dr a current product's Current Control, Cr the GL mapped to {@code financialActivity}. */
|
||||
public void createCurrentControlToFinancialActivityJournalEntries(final Office office, final String currencyCode,
|
||||
final Long currentProductId, final int financialActivity, final Long currentAccountId, final String transactionId,
|
||||
|
||||
+13
@@ -124,6 +124,8 @@ public class SecurityConfig {
|
||||
private ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
|
||||
@Autowired
|
||||
private PlatformUserDetailsChecker platformUserDetailsChecker;
|
||||
@org.springframework.beans.factory.annotation.Value("${fineract.i4c.tenant-identifier:}")
|
||||
private String i4cTenantIdentifier;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
@@ -164,6 +166,16 @@ public class SecurityConfig {
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
||||
// I4C (NCRP-CFCFRMS) server-to-server endpoints — I4C-MHA calls these per its Banks API
|
||||
// Integration Specification 2.0 with its own token flow (POST username/password ->
|
||||
// access_token, then Bearer), not a Fineract login. I4cInboundAuthService inside the
|
||||
// handlers checks the token, its expiry and the caller IP allowlist. The officer screens
|
||||
// under /i4c/admin/** are NOT listed here and stay behind the ordinary login.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/i4c/auth/token")).permitAll()
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/i4c/complaints")).permitAll()
|
||||
// Local stand-in for I4C, off unless fineract.i4c.simulator.enabled=true (the resource
|
||||
// refuses every call otherwise), so UAT can run end to end without the C-DOT VPN.
|
||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/i4c-sim/**")).permitAll()
|
||||
// Client account summary, third-party callers — a partner integration calls this
|
||||
// with an X-Api-Key header instead of a Fineract username/password. The key itself
|
||||
// is issued by inserting directly into m_api_client in the tenant-store database
|
||||
@@ -561,6 +573,7 @@ public class SecurityConfig {
|
||||
userNotificationService, basicAuthTenantDetailsService, businessDateReadPlatformService);
|
||||
|
||||
filter.setRequestMatcher(API_MATCHER.matcher("/api/**"));
|
||||
filter.setI4cDefaultTenantIdentifier(i4cTenantIdentifier);
|
||||
return filter;
|
||||
}
|
||||
|
||||
|
||||
+84
-1
@@ -59,7 +59,7 @@ public class AccountBookDebitService {
|
||||
/** Debits a savings account; returns the m_savings_account_transaction id. */
|
||||
public long debitSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
|
||||
final Long userId) {
|
||||
final BigDecimal balance = checkedBalance("m_savings_account", "Savings", accountId, amount);
|
||||
final BigDecimal balance = checkedSavingsBalance(accountId, amount);
|
||||
kycRestrictionGuard.check(accountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
|
||||
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
|
||||
@@ -89,6 +89,48 @@ public class AccountBookDebitService {
|
||||
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits a savings account by book entry (a returned outward transfer, money coming back to the customer). Like
|
||||
* {@link #debitSavings} it posts no journal entry; the caller posts the one that describes the movement. Returns
|
||||
* the m_savings_account_transaction id.
|
||||
*/
|
||||
public long creditSavings(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
|
||||
final Long userId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?", accountId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", "Savings account " + accountId + " not found.",
|
||||
accountId);
|
||||
}
|
||||
final BigDecimal balance = rows.get(0).get("account_balance_derived") == null ? BigDecimal.ZERO
|
||||
: (BigDecimal) rows.get(0).get("account_balance_derived");
|
||||
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
|
||||
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
final KeyHolder kh = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(con -> {
|
||||
final PreparedStatement ps = con.prepareStatement("INSERT INTO m_savings_account_transaction "
|
||||
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, is_reversed, transaction_date, amount, "
|
||||
+ "submitted_on_date, ref_no, running_balance_derived, is_lien_transaction, created_by, last_modified_by, "
|
||||
+ "created_on_utc, last_modified_on_utc) VALUES (?, ?, NULL, 1, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
|
||||
new String[] { "id" });
|
||||
ps.setLong(1, accountId);
|
||||
ps.setLong(2, officeId);
|
||||
ps.setDate(3, sqlDate);
|
||||
ps.setBigDecimal(4, amount);
|
||||
ps.setDate(5, sqlDate);
|
||||
ps.setString(6, refNo());
|
||||
ps.setBigDecimal(7, balance.add(amount));
|
||||
ps.setLong(8, userId);
|
||||
ps.setLong(9, userId);
|
||||
ps.setObject(10, now);
|
||||
ps.setObject(11, now);
|
||||
return ps;
|
||||
}, kh);
|
||||
jdbcTemplate.update("UPDATE m_savings_account SET account_balance_derived = account_balance_derived + ?, "
|
||||
+ "total_deposits_derived = COALESCE(total_deposits_derived, 0) + ? WHERE id = ?", amount, amount, accountId);
|
||||
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||
}
|
||||
|
||||
/** Debits a current account; returns the m_current_account_transaction id. */
|
||||
public long debitCurrent(final Long accountId, final BigDecimal amount, final Long officeId, final LocalDate txnDate,
|
||||
final Long userId, final String note) {
|
||||
@@ -119,6 +161,47 @@ public class AccountBookDebitService {
|
||||
return Objects.requireNonNull(kh.getKey()).longValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* A savings account's balance, after checking it is active, not blocked for debits, and can cover {@code amount}
|
||||
* from what is withdrawable: the balance less every kind of hold (lien, amount hold such as an I4C cyber-fraud hold,
|
||||
* clearing hold) and the enforced minimum balance, plus any overdraft limit. The generic check below only took the
|
||||
* lien, so a book debit (a demand draft, an outward transfer) could take money an amount hold was guarding.
|
||||
*/
|
||||
private BigDecimal checkedSavingsBalance(final Long accountId, final BigDecimal amount) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT status_enum, sub_status_enum, COALESCE(account_balance_derived, 0) AS balance,
|
||||
COALESCE(on_hold_funds_derived, 0) + COALESCE(total_savings_amount_on_hold, 0)
|
||||
+ COALESCE(total_savings_amount_on_hold_clearing, 0) AS held,
|
||||
CASE WHEN enforce_min_required_balance THEN COALESCE(min_required_balance, 0) ELSE 0 END AS min_balance,
|
||||
CASE WHEN allow_overdraft THEN COALESCE(overdraft_limit, 0) ELSE 0 END AS overdraft
|
||||
FROM m_savings_account WHERE id = ?
|
||||
""", accountId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.account.not.found", "Savings account " + accountId + " not found.",
|
||||
accountId);
|
||||
}
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
if (((Number) r.get("status_enum")).intValue() != 300) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.account.not.active", "Savings account " + accountId + " is not active.",
|
||||
accountId);
|
||||
}
|
||||
final int subStatus = r.get("sub_status_enum") == null ? 0 : ((Number) r.get("sub_status_enum")).intValue();
|
||||
if (subStatus == 400 || subStatus == 600) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.account.debit.blocked",
|
||||
"Savings account " + accountId + " is blocked for debits.", accountId);
|
||||
}
|
||||
final BigDecimal balance = (BigDecimal) r.get("balance");
|
||||
final BigDecimal available = balance.subtract((BigDecimal) r.get("held")).subtract((BigDecimal) r.get("min_balance"))
|
||||
.add((BigDecimal) r.get("overdraft"));
|
||||
if (available.compareTo(amount) < 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.account.insufficient.balance",
|
||||
"Insufficient available balance in savings account " + accountId + ". Available: " + available + ", Requested: "
|
||||
+ amount,
|
||||
accountId);
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
/** The account's balance, after checking it is active and can cover {@code amount} from what is not on hold. */
|
||||
private BigDecimal checkedBalance(final String table, final String kind, final Long accountId, final BigDecimal amount) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
|
||||
-17
@@ -46,8 +46,6 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentFdInterestService;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator;
|
||||
@@ -97,16 +95,8 @@ public class BranchDayRegisterApiResource {
|
||||
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
|
||||
private final FixedDepositInterestPostingReportService fdInterestPostingReportService;
|
||||
private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator;
|
||||
private final InvestmentPurchaseReadPlatformService investmentPurchaseReadPlatformService;
|
||||
private final InvestmentFdInterestService investmentFdInterestService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private boolean isHeadOffice(final Long officeId) {
|
||||
final Integer topLevel = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ? AND parent_id IS NULL",
|
||||
Integer.class, officeId);
|
||||
return topLevel != null && topLevel > 0;
|
||||
}
|
||||
|
||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -270,13 +260,6 @@ public class BranchDayRegisterApiResource {
|
||||
final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate);
|
||||
result.put("fdInterestPending", fdInterest.get("pending"));
|
||||
result.put("fdInterestDue", fdInterest.get("due"));
|
||||
// Investment interest vouchers (coupon / provision) whose date has come but which are not posted yet.
|
||||
// A reminder only: it never stops the close (Jitendra Sir). Investments are booked at Head Office, so
|
||||
// only the Head Office close shows it.
|
||||
result.put("investmentInterestPending",
|
||||
isHeadOffice(officeId) ? investmentPurchaseReadPlatformService.retrieveDueInterest(0) : List.of());
|
||||
// The same for FDs placed with other banks: interest vouchers and maturities due. A reminder only.
|
||||
result.put("investmentFdPending", isHeadOffice(officeId) ? investmentFdInterestService.due(0) : List.of());
|
||||
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* 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.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
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.I4cAdminReadService;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cComplaintProcessor;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cHoldService;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cIntimationService;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cListQuery;
|
||||
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.I4cSystemContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Officer screens for the I4C integration, behind the ordinary CBS login.
|
||||
*
|
||||
* READ_I4C to see anything; REQUESTACTION_I4CHOLD to ask for a release or a debit freeze/unfreeze;
|
||||
* APPROVEACTION_I4CHOLD to approve or reject one (never your own); INTIMATE_I4CHOLD to record that the customer was
|
||||
* told; RETRY_I4C to resend a failed response or reprocess a complaint.
|
||||
*/
|
||||
@Path("/v1/i4c/admin")
|
||||
@Component
|
||||
@Tag(name = "I4C CFCFRMS (officer)", description = "Monitoring and actions on I4C complaints, holds and responses.")
|
||||
@RequiredArgsConstructor
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class I4cAdminApiResource {
|
||||
|
||||
private static final String RESOURCE = "I4C";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final I4cAdminReadService readService;
|
||||
private final I4cHoldService holdService;
|
||||
private final I4cIntimationService intimationService;
|
||||
private final I4cOutboxService outboxService;
|
||||
private final I4cProcessingRunner runner;
|
||||
private final I4cSystemContext systemContext;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Path("dashboard")
|
||||
@Operation(summary = "Integration health, volumes, holds and the API performance figures for periodic review")
|
||||
public String dashboard() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.dashboard());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("requests")
|
||||
@Operation(summary = "Complaints, one page at a time: status, search, subCategory, layer, response, fromDate, toDate, offset, limit")
|
||||
public String requests(@Context final UriInfo uriInfo) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.requests(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("requests/{id}")
|
||||
public String request(@PathParam("id") final Long id) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.request(id));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("requests/{id}/reprocess")
|
||||
@Operation(summary = "Process a complaint now that is waiting for a retry (idempotent: holds are never placed twice)")
|
||||
public String reprocess(@PathParam("id") final Long id) {
|
||||
context.authenticatedUser().validateHasPermissionTo("RETRY_I4C");
|
||||
final int updated = jdbcTemplate.update("UPDATE m_i4c_request SET status = ? WHERE id = ? AND status IN (?, ?)",
|
||||
I4cComplaintProcessor.RETRY, id, I4cComplaintProcessor.RETRY, I4cComplaintProcessor.RECEIVED);
|
||||
if (updated == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.request.not.retryable",
|
||||
"Only a complaint that is RECEIVED or waiting for RETRY can be reprocessed; to resend a reply use the outbox retry.");
|
||||
}
|
||||
systemContext.runAsSystem(() -> runner.processAndSend(id));
|
||||
return gson.toJson(readService.request(id));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("holds")
|
||||
@Operation(summary = "Holds register: view, search, accountType, intimation, frozen, layer, fromDate, toDate, offset, limit")
|
||||
public String holds(@Context final UriInfo uriInfo) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.holds(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("holds/{id}")
|
||||
public String hold(@PathParam("id") final Long id) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.hold(id));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{id}/actions")
|
||||
@Operation(summary = "Maker: ask to RELEASE the hold, or to FREEZE / UNFREEZE debits on the account")
|
||||
public String requestAction(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("REQUESTACTION_I4CHOLD");
|
||||
final JsonObject json = parse(body);
|
||||
final String orderDate = text(json, "orderDate");
|
||||
final String amount = text(json, "amount");
|
||||
final String action = text(json, "action");
|
||||
if ("RESTORE".equalsIgnoreCase(action == null ? "" : action.trim())) {
|
||||
// A restoration rests on its MRM record and its papers; it is raised from there, not as a bare request.
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.restore.from.mrm",
|
||||
"Raise a restoration from its MRM record (Restorations page, bank action Compliance).");
|
||||
}
|
||||
final java.math.BigDecimal releaseAmount;
|
||||
try {
|
||||
releaseAmount = amount == null || amount.isBlank() ? null : new java.math.BigDecimal(amount.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.number.invalid", "amount must be a number.", e);
|
||||
}
|
||||
final Long requestId = holdService.requestAction(id, action, text(json, "reasonType"), text(json, "orderReference"),
|
||||
orderDate == null || orderDate.isBlank() ? null : parseDate(orderDate), text(json, "authority"), text(json, "remarks"),
|
||||
releaseAmount, null, null, user);
|
||||
return result("resourceId", requestId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("hold-actions")
|
||||
@Operation(summary = "Release / freeze requests: status, action, reasonType, search, fromDate, toDate, offset, limit")
|
||||
public String holdActions(@Context final UriInfo uriInfo) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.holdActions(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("hold-actions/{id}/approve")
|
||||
@Operation(summary = "Checker: approve and carry out a release / freeze / unfreeze request raised by someone else")
|
||||
public String approve(@PathParam("id") final Long id) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("APPROVEACTION_I4CHOLD");
|
||||
holdService.approveAction(id, user);
|
||||
return result("resourceId", id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("hold-actions/{id}/reject")
|
||||
public String reject(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("APPROVEACTION_I4CHOLD");
|
||||
holdService.rejectAction(id, text(parse(body), "reason"), user);
|
||||
return result("resourceId", id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{id}/intimation")
|
||||
@Operation(summary = "Record that the account holder was told about the hold")
|
||||
public String intimation(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("INTIMATE_I4CHOLD");
|
||||
final JsonObject json = parse(body);
|
||||
holdService.markIntimated(id, text(json, "mode"), text(json, "reference"), user);
|
||||
intimationService.logManual(id, text(json, "mode"), text(json, "reference"), user.getDisplayName());
|
||||
return result("resourceId", id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{id}/intimation/send")
|
||||
@Operation(summary = "Send the hold notice to the customer now, on the switched-on automatic channels")
|
||||
public String sendIntimation(@PathParam("id") final Long id) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("INTIMATE_I4CHOLD");
|
||||
return gson.toJson(resultMap(intimationService.sendNow(id, user.getDisplayName())));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("intimation-channels")
|
||||
@Operation(summary = "Automatic customer-intimation channels, their order and message text")
|
||||
public String intimationChannels() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("channels", intimationService.channels());
|
||||
out.put("placeholders", I4cIntimationService.PLACEHOLDERS);
|
||||
out.put("manualChannels", java.util.List.of("PHONE", "LETTER", "IN_PERSON", "SMS", "EMAIL", "WHATSAPP"));
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("intimation-channels/{channel}")
|
||||
@Operation(summary = "Switch an automatic intimation channel on or off, reorder it, or change its message")
|
||||
public String updateIntimationChannel(@PathParam("channel") final String channel, final String body) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CONFIGURE_I4C");
|
||||
final JsonObject json = parse(body);
|
||||
intimationService.updateChannel(channel, json.has("enabled") && !json.get("enabled").isJsonNull() ? json.get("enabled").getAsBoolean() : null,
|
||||
json.has("priority") && !json.get("priority").isJsonNull() ? json.get("priority").getAsInt() : null, text(json, "subject"),
|
||||
text(json, "body"), user.getDisplayName());
|
||||
return gson.toJson(intimationService.channels());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("intimation-channels/{channel}/test")
|
||||
@Operation(summary = "Send the channel's message, filled with sample values, to a test address (EMAIL)")
|
||||
public String testIntimationChannel(@PathParam("channel") final String channel, final String body) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CONFIGURE_I4C");
|
||||
return gson.toJson(resultMap(intimationService.sendTest(channel, text(parse(body), "to"))));
|
||||
}
|
||||
|
||||
private static Map<String, Object> resultMap(final I4cIntimationService.Result r) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("outcome", r.outcome().name());
|
||||
out.put("channel", r.channel());
|
||||
out.put("destination", r.destination());
|
||||
out.put("message", r.message());
|
||||
return out;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("outbox")
|
||||
@Operation(summary = "Messages to I4C: status, type (ACK / RESPONSE), search, fromDate, toDate, offset, limit")
|
||||
public String outbox(@Context final UriInfo uriInfo) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.outbox(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("outbox/{id}/retry")
|
||||
public String retryOutbox(@PathParam("id") final Long id) {
|
||||
context.authenticatedUser().validateHasPermissionTo("RETRY_I4C");
|
||||
outboxService.retry(id);
|
||||
final boolean sent = outboxService.send(id);
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", id);
|
||||
out.put("sent", sent);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("api-log")
|
||||
@Operation(summary = "API log: direction, failures, httpStatus, responseCode, search, fromDate, toDate, offset, limit")
|
||||
public String apiLog(@Context final UriInfo uriInfo) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.apiLog(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("api-log/{id}")
|
||||
public String apiLogEntry(@PathParam("id") final Long id) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.apiLogEntry(id));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("bank-master")
|
||||
@Operation(summary = "I4C bank code master: search, type, ifsc (KNOWN / UNKNOWN), offset, limit")
|
||||
public String bankMaster(@Context final UriInfo uriInfo) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.bankMaster(I4cListQuery.of(uriInfo.getQueryParameters(), 50)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("configuration")
|
||||
public String configuration() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||
return gson.toJson(readService.configuration());
|
||||
}
|
||||
|
||||
private String result(final String key, final Object value) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put(key, value);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(final String value) {
|
||||
try {
|
||||
return LocalDate.parse(value.trim());
|
||||
} catch (final java.time.format.DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.order.date.invalid", "Order date must be yyyy-MM-dd.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject parse(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(body).getAsJsonObject();
|
||||
} catch (final RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.body.invalid", "Request body must be a JSON object.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
|
||||
}
|
||||
}
|
||||
+836
@@ -0,0 +1,836 @@
|
||||
/**
|
||||
* 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.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
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;
|
||||
|
||||
/**
|
||||
* The bank's side of the NCRP GRM (grievances against holds) and MRM (money restoration) portals, and the documents
|
||||
* behind them. The portals have no API: officers act there and record it here, and orders are carried out on the
|
||||
* account through the same maker-checker requests as every other hold change.
|
||||
*
|
||||
* READ_I4C to see; MANAGE_I4CGRIEVANCE / MANAGE_I4CRESTORATION to record; REQUESTACTION_I4CHOLD to raise the account
|
||||
* change an order needs; APPROVEACTION_I4CHOLD to resend a restoration waiting in the pool.
|
||||
*/
|
||||
@Path("/v1/i4c/cases")
|
||||
@Component
|
||||
@Tag(name = "I4C GRM / MRM (officer)", description = "Grievances, money restoration, victim-bank cases and their documents.")
|
||||
@RequiredArgsConstructor
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class I4cCasesApiResource {
|
||||
|
||||
private static final String READ = "I4C";
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("grievances")
|
||||
@Operation(summary = "GRM grievances: stage (or OPEN), status, overdue, search, fromDate, toDate, offset, limit")
|
||||
public String grievances(@Context final UriInfo uriInfo) {
|
||||
reader();
|
||||
return gson.toJson(readService.grievances(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("grievances/{id}")
|
||||
public String grievance(@PathParam("id") final Long id) {
|
||||
reader();
|
||||
return gson.toJson(readService.grievance(id));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{holdId}/grievances")
|
||||
@Operation(summary = "Open a grievance (draft) when the account holder asks the branch to remove the hold / unfreeze")
|
||||
public String createGrievance(@PathParam("holdId") final Long holdId, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
final JsonObject json = parse(body);
|
||||
return created(grievances.create(holdId, text(json, "customerRequest"), text(json, "customerReason"), user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/edd")
|
||||
@Operation(summary = "Record the enhanced due diligence done before raising the grievance on GRM")
|
||||
public String edd(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
final JsonObject json = parse(body);
|
||||
grievances.recordEdd(id, bool(json, "kycVerified"), text(json, "sourceOfFunds"), text(json, "customerStatement"), text(json, "risk"),
|
||||
text(json, "recommendation"), user);
|
||||
return grievance(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/submit")
|
||||
@Operation(summary = "Raised on GRM: record the GR id (needs the EDD and the KYC, application, photo and statement)")
|
||||
public String submit(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
grievances.submit(id, text(parse(body), "grievanceNo"), user);
|
||||
return grievance(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/nodal")
|
||||
@Operation(summary = "Bank GRM nodal officer: approve (on to the IO, 7 days) or reject")
|
||||
public String nodal(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
final JsonObject json = parse(body);
|
||||
grievances.nodal(id, Boolean.TRUE.equals(bool(json, "approve")), text(json, "remarks"), user);
|
||||
return grievance(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/events")
|
||||
@Operation(summary = "Note a GRM event: VC_REQUESTED, VC_DONE, VC_NOT_REQUIRED, INFO_SOUGHT, INFO_SENT, NOTE")
|
||||
public String note(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
final JsonObject json = parse(body);
|
||||
grievances.note(id, text(json, "eventType"), text(json, "remarks"), user);
|
||||
return grievance(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/investigation")
|
||||
@Operation(summary = "The IO is investigating: another 15 days")
|
||||
public String investigation(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
grievances.investigation(id, text(parse(body), "remarks"), user);
|
||||
return grievance(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/decision")
|
||||
@Operation(summary = "The IO / DGRO / SGRO decision as GRM shows it")
|
||||
public String decision(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
final JsonObject json = parse(body);
|
||||
grievances.decide(id, text(json, "level"), text(json, "decision"), decimal(json, "keepAmount"), text(json, "orderRef"),
|
||||
date(json, "orderDate"), text(json, "remarks"), user);
|
||||
return grievance(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/action")
|
||||
@Operation(summary = "Maker: raise the account change an allowing order needs; a checker approves it on the Approvals page")
|
||||
public String raiseGrievanceAction(@PathParam("id") final Long id) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
user.validateHasPermissionTo(REQUEST_ACTION);
|
||||
final Long requestId = grievances.raiseAction(id, user);
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", id);
|
||||
out.put("actionRequestId", requestId);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/escalate")
|
||||
@Operation(summary = "After a rejection: review by the District GRO, then the State GRO, then the courts")
|
||||
public String escalate(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
grievances.escalate(id, text(parse(body), "remarks"), user);
|
||||
return grievance(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("grievances/{id}/withdraw")
|
||||
public String withdraw(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(GRIEVANCE);
|
||||
grievances.withdraw(id, text(parse(body), "remarks"), user);
|
||||
return grievance(id);
|
||||
}
|
||||
|
||||
// ── MRM ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("restorations")
|
||||
@Operation(summary = "MRM restorations and victim-bank cases: role, status (or OPEN), bankAction, search, fromDate, toDate, offset, limit")
|
||||
public String restorations(@Context final UriInfo uriInfo) {
|
||||
reader();
|
||||
return gson.toJson(readService.restorations(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("restorations/{id}")
|
||||
public String restoration(@PathParam("id") final Long id) {
|
||||
reader();
|
||||
return gson.toJson(readService.restoration(id));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{holdId}/restorations")
|
||||
@Operation(summary = "Assess a complaint for MRM: ATTRIBUTION / PRO_RATA with the restorable amount, or NOT_POSSIBLE_* with the reason")
|
||||
public String assess(@PathParam("holdId") final Long holdId, final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
final JsonObject json = parse(body);
|
||||
return created(restorations.assess(holdId, text(json, "basis"), decimal(json, "restorableAmount"), text(json, "remarks"), user));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("restorations/{id}/request")
|
||||
@Operation(summary = "Record the victim's MRM request: MR id, refund account, IO, FIR, court order, BNSS 106 notice, indemnity bond")
|
||||
public String recordRequest(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
final Map<String, Object> fields = new LinkedHashMap<>();
|
||||
for (final Map.Entry<String, JsonElement> e : parse(body).entrySet()) {
|
||||
final JsonElement v = e.getValue();
|
||||
fields.put(e.getKey(), v == null || v.isJsonNull() ? null
|
||||
: v.isJsonPrimitive() && v.getAsJsonPrimitive().isBoolean() ? (Object) v.getAsBoolean() : v.getAsString());
|
||||
}
|
||||
restorations.recordRequest(id, fields, user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/bank-action")
|
||||
@Operation(summary = "MRM bank action: COMPLIED (raises the RESTORE request), NOT_COMPLIED, REFER_BACK_LEA, REFER_BACK_VICTIM_BANK, CONSENSUS")
|
||||
public String bankAction(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
final JsonObject json = parse(body);
|
||||
if ("COMPLIED".equalsIgnoreCase(text(json, "action"))) {
|
||||
user.validateHasPermissionTo(REQUEST_ACTION);
|
||||
}
|
||||
final Long requestId = restorations.bankAction(id, text(json, "action"), text(json, "reason"), user);
|
||||
final Map<String, Object> out = new LinkedHashMap<>(readService.restoration(id));
|
||||
out.put("actionRequestId", requestId);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/retry-outward")
|
||||
@Operation(summary = "Send a restoration waiting in the pool to the victim's bank again")
|
||||
public String retryOutward(@PathParam("id") final Long id) {
|
||||
user("APPROVEACTION_I4CHOLD");
|
||||
restorations.retryOutward(id);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/mrm-updated")
|
||||
@Operation(summary = "The officer has entered this on the MRM portal (restoration done, assessment or victim-bank receipt)")
|
||||
public String mrmUpdated(@PathParam("id") final Long id) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
restorations.markMrmUpdated(id, user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("victim-cases")
|
||||
@Operation(summary = "Open a victim-bank case for a layer 0 complaint (our customer lost the money)")
|
||||
public String openVictimCase(final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
return created(restorations.openVictimCase(text(parse(body), "acknowledgementNo"), user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/receipt")
|
||||
@Operation(summary = "Victim bank: restored money credited to our customer")
|
||||
public String receipt(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
final JsonObject json = parse(body);
|
||||
restorations.recordReceipt(id, decimal(json, "amount"), text(json, "utr"), date(json, "receivedOn"), text(json, "accountNo"), user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/victim-status")
|
||||
@Operation(summary = "Victim bank: why no (more) restoration is expected (already refunded, declined, not reachable...)")
|
||||
public String victimStatus(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
final JsonObject json = parse(body);
|
||||
restorations.victimStatus(id, text(json, "status"), text(json, "remarks"), user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("pro-rata")
|
||||
@Operation(summary = "Pro-rata split of an amount across the active holds on one account, by disputed amount")
|
||||
public String proRata(@QueryParam("accountNo") final String accountNo, @QueryParam("amount") final String amount) {
|
||||
reader();
|
||||
return gson.toJson(restorations.proRata(accountNo, amount == null || amount.isBlank() ? null : number(amount, "amount")));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("mrm-balance-report")
|
||||
@Operation(summary = "MRM 'Update Account Balance': accounts with active I4C holds, their balances and complaints")
|
||||
public String balanceReport() {
|
||||
reader();
|
||||
return gson.toJson(restorations.balanceReport());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("mrm-balance-report.csv")
|
||||
@Produces("text/csv")
|
||||
public Response balanceReportCsv() {
|
||||
reader();
|
||||
final List<Map<String, Object>> rows = restorations.balanceReport();
|
||||
final StringBuilder csv = new StringBuilder(
|
||||
"Account Number,Account Type,Account Holder,Branch,Account Status,Ledger Balance,Available Balance,Amount On Hold,Disputed,"
|
||||
+ "Shortfall,Complaints,Acknowledgement Numbers,Debit Frozen\n");
|
||||
for (final Map<String, Object> r : rows) {
|
||||
csv.append(String.join(",", cell(r.get("account_no")), cell(r.get("account_type")), cell(r.get("client_name")),
|
||||
cell(r.get("office_name")), cell(r.get("account_status")), cell(r.get("ledger_balance")), cell(r.get("available_balance")),
|
||||
cell(r.get("held")), cell(r.get("disputed")), cell(r.get("shortfall")), cell(r.get("complaints")),
|
||||
cell(r.get("acknowledgement_nos")), cell(r.get("debit_frozen")))).append('\n');
|
||||
}
|
||||
return csvResponse(csv.toString(), "mrm-balance-report-" + LocalDate.now() + ".csv");
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/contest")
|
||||
@Operation(summary = "The suspect contested the refund (SOP: within 15 days of it)")
|
||||
public String contest(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
final JsonObject json = parse(body);
|
||||
restorations.recordContest(id, date(json, "contestedOn"), text(json, "remarks"), user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/liability")
|
||||
@Operation(summary = "Victim bank: category, txnDate, reportedOn, ncrpReportedOn, crossBorder, grossLoss, recoveredAmount;"
|
||||
+ " RBI 2017 or 2026 framework by the transaction date")
|
||||
public String liability(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
restorations.assessLiability(id, fields(body), user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/compensation/apply")
|
||||
@Operation(summary = "Victim bank: the customer applied for the RBI 2026 small-value compensation (paid within 5 calendar days)")
|
||||
public String applyCompensation(@PathParam("id") final Long id) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
restorations.applyCompensation(id, user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/compensation/decision")
|
||||
@Operation(summary = "Victim bank, checker: pay (credit the customer) or reject the compensation")
|
||||
public String payCompensation(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(APPROVE_ACTION);
|
||||
restorations.payCompensation(id, Boolean.TRUE.equals(bool(parse(body), "approve")), user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/shadow-credit/finalise")
|
||||
@Operation(summary = "Victim bank: zero liability established; the provisional shadow reversal becomes usable")
|
||||
public String finaliseShadowCredit(@PathParam("id") final Long id) {
|
||||
final AppUser user = user(APPROVE_ACTION);
|
||||
restorations.finaliseShadowCredit(id, user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("compensation-claims.csv")
|
||||
@Produces("text/csv")
|
||||
@Operation(summary = "Compensations paid in a period (a quarter), for the claim to RBI (Annex II(2)) and the beneficiary banks")
|
||||
public Response compensationClaims(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
reader();
|
||||
final LocalDate to = toDate == null || toDate.isBlank() ? LocalDate.now() : parseDate(toDate, "toDate");
|
||||
final LocalDate from = fromDate == null || fromDate.isBlank() ? to.minusMonths(3) : parseDate(fromDate, "fromDate");
|
||||
final StringBuilder csv = new StringBuilder("Case,Ack No,Account,Txn Date,Reported to Bank,Reported to NCRP,Gross Loss,Recovered,"
|
||||
+ "Compensation,RBI Share,Own Share,Beneficiary Bank Share,Cross-border,Paid On\n");
|
||||
for (final Map<String, Object> r : restorations.compensationClaims(from, to)) {
|
||||
csv.append(String.join(",", cell(r.get("id")), cell(r.get("acknowledgement_no")), cell(r.get("victim_account_no")),
|
||||
cell(r.get("txn_date")), cell(r.get("customer_reported_on")), cell(r.get("ncrp_reported_on")), cell(r.get("gross_loss")),
|
||||
cell(r.get("recovered_amount")), cell(r.get("comp_amount")), cell(r.get("comp_share_rbi")), cell(r.get("comp_share_own")),
|
||||
cell(r.get("comp_share_beneficiary")), cell(r.get("cross_border")), cell(r.get("comp_paid_on")))).append('\n');
|
||||
}
|
||||
return csvResponse(csv.toString(), "fraud-compensation-claims-" + from + "-to-" + to + ".csv");
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/shadow-credit")
|
||||
@Operation(summary = "Victim bank, maker: ask for a shadow credit to the customer")
|
||||
public String requestShadowCredit(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RESTORATION);
|
||||
restorations.requestShadowCredit(id, decimal(parse(body), "amount"), user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/shadow-credit/decision")
|
||||
@Operation(summary = "Victim bank, checker: approve (credit the customer) or reject the shadow credit")
|
||||
public String decideShadowCredit(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(APPROVE_ACTION);
|
||||
restorations.approveShadowCredit(id, Boolean.TRUE.equals(bool(parse(body), "approve")), user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("restorations/{id}/regularise")
|
||||
@Operation(summary = "Victim bank: recover the shadow credit once restored money arrived")
|
||||
public String regularise(@PathParam("id") final Long id) {
|
||||
final AppUser user = user(APPROVE_ACTION);
|
||||
restorations.regularise(id, user);
|
||||
return restoration(id);
|
||||
}
|
||||
|
||||
// ── Risk: mule register, fraud reporting, linked repayments, 90-day lapse, channel suspension ──
|
||||
|
||||
@GET
|
||||
@Path("mule-flags")
|
||||
@Operation(summary = "Mule-account register: status, search, fromDate, toDate, offset, limit")
|
||||
public String muleFlags(@Context final UriInfo uriInfo) {
|
||||
reader();
|
||||
return gson.toJson(readService.muleFlags(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{holdId}/mule-flag")
|
||||
public String flagMule(@PathParam("holdId") final Long holdId, final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
return created(risk.flagManually(holdId, text(json, "source"), text(json, "reason"), user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("mule-flags/{id}/review")
|
||||
@Operation(summary = "CONFIRMED (a mule) or CLEARED (not a mule), with the basis")
|
||||
public String reviewMule(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
risk.reviewMule(id, text(json, "outcome"), text(json, "remarks"), user);
|
||||
return created(id);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("fraud-reports")
|
||||
@Operation(summary = "Fraud reporting register (police, RBI FMR, FIU-IND STR): status, overdue, search, fromDate, toDate, offset, limit")
|
||||
public String fraudReports(@Context final UriInfo uriInfo) {
|
||||
reader();
|
||||
return gson.toJson(readService.fraudReports(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{holdId}/fraud-report")
|
||||
@Operation(summary = "Classify the account as a fraud case today and open its reporting record")
|
||||
public String openFraudReport(@PathParam("holdId") final Long holdId) {
|
||||
final AppUser user = user(RISK);
|
||||
return created(risk.openFraudReport(holdId, user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("fraud-reports/{id}/{step}")
|
||||
@Operation(summary = "show-cause {sentOn} | response {respondedOn, response} | classification {fraud, orderRef, orderOn}"
|
||||
+ " (UCB Fraud Risk Management Directions, 2026: 21 days and a reasoned order before classification)")
|
||||
public String fraudReportStep(@PathParam("id") final Long id, @PathParam("step") final String step, final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
switch (step) {
|
||||
case "show-cause" -> risk.recordShowCause(id, date(json, "sentOn"), user);
|
||||
case "response" -> risk.recordShowCauseResponse(id, date(json, "respondedOn"), text(json, "response"), user);
|
||||
case "classification" -> risk.decideClassification(id, Boolean.TRUE.equals(bool(json, "fraud")), text(json, "orderRef"),
|
||||
date(json, "orderOn"), user);
|
||||
default -> throw new PlatformDataIntegrityException("error.msg.i4c.fraud.step", "Unknown step " + step + ".");
|
||||
}
|
||||
return created(id);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("fraud-reports/{id}")
|
||||
@Operation(summary = "Record FMR, STR and police references and dates")
|
||||
public String updateFraudReport(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
risk.updateFraudReport(id, fields(body), user);
|
||||
return created(id);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("fraud-reports.csv")
|
||||
@Produces("text/csv")
|
||||
public Response fraudReportsCsv() {
|
||||
reader();
|
||||
final StringBuilder csv = new StringBuilder("Account Number,Customer,Acknowledgement Numbers,Amount,Classified On,FMR Due,FMR Ref,FMR Filed,"
|
||||
+ "STR Due,STR Ref,STR Filed,Police Ref,Police Reported,Status,Remarks\n");
|
||||
final Map<String, Object> page = readService.fraudReports(I4cListQuery.of(Map.of("limit", List.of("500")), 500));
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<Map<String, Object>> rows = (List<Map<String, Object>>) page.get("pageItems");
|
||||
for (final Map<String, Object> r : rows) {
|
||||
csv.append(String.join(",", cell(r.get("account_no")), cell(r.get("client_name")), cell(r.get("acknowledgement_nos")),
|
||||
cell(r.get("amount")), cell(r.get("classified_on")), cell(r.get("fmr_due_on")), cell(r.get("fmr_ref")),
|
||||
cell(r.get("fmr_filed_on")), cell(r.get("str_due_on")), cell(r.get("str_ref")), cell(r.get("str_filed_on")),
|
||||
cell(r.get("police_ref")), cell(r.get("police_reported_on")), cell(r.get("status")), cell(r.get("remarks")))).append('\n');
|
||||
}
|
||||
return csvResponse(csv.toString(), "i4c-fraud-reports-" + LocalDate.now() + ".csv");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("linked-alerts")
|
||||
@Operation(summary = "Fraud money that repaid loans here: status, search, fromDate, toDate, offset, limit")
|
||||
public String linkedAlerts(@Context final UriInfo uriInfo) {
|
||||
reader();
|
||||
return gson.toJson(readService.linkedAlerts(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("linked-alerts/{id}")
|
||||
@Operation(summary = "ACTIONED (amount held on the loan / linked account told) or DISMISSED, with remarks")
|
||||
public String actionAlert(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
risk.actionAlert(id, text(json, "outcome"), text(json, "remarks"), user);
|
||||
return created(id);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("channel-blocks")
|
||||
@Operation(summary = "Digital-channel suspensions: status, search, fromDate, toDate, offset, limit")
|
||||
public String channelBlocks(@Context final UriInfo uriInfo) {
|
||||
reader();
|
||||
return gson.toJson(readService.channelBlocks(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{holdId}/lea-notice")
|
||||
@Operation(summary = "SOP 90-day rule: the notice sent to the LEA 15 days before the hold lapses")
|
||||
public String leaNotice(@PathParam("holdId") final Long holdId, final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
risk.recordLeaNotice(holdId, date(json, "sentOn"), text(json, "reference"), user);
|
||||
return created(holdId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("holds/{holdId}/extension")
|
||||
@Operation(summary = "SOP 90-day rule: the LEA extended the hold (a lawful direction)")
|
||||
public String extension(@PathParam("holdId") final Long holdId, final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
risk.recordExtension(holdId, date(json, "until"), text(json, "reference"), user);
|
||||
return created(holdId);
|
||||
}
|
||||
|
||||
// ── IFSC -> I4C Bank Code map (so onward transfers name the next bank) ──
|
||||
|
||||
@GET
|
||||
@Path("ifsc-map")
|
||||
@Operation(summary = "The bank's IFSC / prefix -> I4C Bank Code map, and reported IFSCs that had no code")
|
||||
public String ifscMap() {
|
||||
reader();
|
||||
return gson.toJson(readService.ifscMap());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("ifsc-map")
|
||||
public String mapIfsc(final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
final Integer code = json.has("bankCode") && !json.get("bankCode").isJsonNull() ? json.get("bankCode").getAsInt() : null;
|
||||
return created(risk.mapIfsc(text(json, "ifscKey"), code, text(json, "note"), user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("ifsc-map/{id}/remove")
|
||||
public String unmapIfsc(@PathParam("id") final Long id) {
|
||||
user(RISK);
|
||||
risk.unmapIfsc(id);
|
||||
return created(id);
|
||||
}
|
||||
|
||||
// ── Nodal / pool / escrow exclusions ────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("exclusions")
|
||||
@Operation(summary = "Nodal / pool / escrow accounts not held on I4C complaints: status, search, offset, limit")
|
||||
public String exclusions(@Context final UriInfo uriInfo) {
|
||||
reader();
|
||||
return gson.toJson(readService.exclusions(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("exclusions")
|
||||
public String addExclusion(final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
final String accountNo = text(json, "accountNo");
|
||||
final I4cAccountGateway.AccountRef account = gateway.resolve(accountNo)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.i4c.account.not.found", "Account " + accountNo + " not found."));
|
||||
return created(risk.addExclusion(account, text(json, "kind"), text(json, "reason"), user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("exclusions/{id}/remove")
|
||||
public String removeExclusion(@PathParam("id") final Long id) {
|
||||
final AppUser user = user(RISK);
|
||||
risk.removeExclusion(id, user);
|
||||
return created(id);
|
||||
}
|
||||
|
||||
// ── Bank-initiated debit holds (RBI draft mule SOP, Sep 2026) ───────────
|
||||
|
||||
@GET
|
||||
@Path("bank-holds")
|
||||
@Operation(summary = "Bank-initiated suspected-mule holds: status (or OPEN), due, search, fromDate, toDate, offset, limit")
|
||||
public String bankHolds(@Context final UriInfo uriInfo) {
|
||||
reader();
|
||||
return gson.toJson(readService.bankHolds(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("bank-holds")
|
||||
@Operation(summary = "Hold a suspected mule transaction (Rs 1,000+) at once, or the whole account as a last resort")
|
||||
public String placeBankHold(final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
return created(bankHolds.place(text(json, "accountNo"), text(json, "scope"), text(json, "trigger"), text(json, "txnRef"),
|
||||
decimal(json, "amount"), text(json, "reason"), user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("bank-holds/{id}/{step}")
|
||||
@Operation(summary = "notice {mode, reference} | explanation {receivedOn, explanation} | decision {decision: LIFT|REFER, remarks, ncrpRef}"
|
||||
+ " | police {instruction: CONTINUE|LIFT, reference} | grievance {text} | grievance-resolution {resolution}")
|
||||
public String bankHoldStep(@PathParam("id") final Long id, @PathParam("step") final String step, final String body) {
|
||||
final AppUser user = user(RISK);
|
||||
final JsonObject json = parse(body);
|
||||
switch (step) {
|
||||
case "notice" -> bankHolds.recordNotice(id, text(json, "mode"), text(json, "reference"), user);
|
||||
case "explanation" -> bankHolds.recordExplanation(id, date(json, "receivedOn"), text(json, "explanation"), user);
|
||||
case "decision" -> bankHolds.decide(id, text(json, "decision"), text(json, "remarks"), text(json, "ncrpRef"), user);
|
||||
case "police" -> bankHolds.recordPolice(id, text(json, "instruction"), text(json, "reference"), user);
|
||||
case "grievance" -> bankHolds.raiseGrievance(id, text(json, "text"), user);
|
||||
case "grievance-resolution" -> bankHolds.resolveGrievance(id, text(json, "resolution"), user);
|
||||
default -> throw new PlatformDataIntegrityException("error.msg.i4c.bankhold.step", "Unknown step " + step + ".");
|
||||
}
|
||||
return created(id);
|
||||
}
|
||||
|
||||
// ── Statement and documents ─────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("holds/{holdId}/statement.csv")
|
||||
@Produces("text/csv")
|
||||
@Operation(summary = "Statement of the account on hold for a period (default: 90 days before the hold to today), for GRM / MRM")
|
||||
public Response statementCsv(@PathParam("holdId") final Long holdId, @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.minusDays(90) : parseDate(fromDate, "fromDate");
|
||||
if (to.isBefore(from)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.date.range.invalid", "The To date cannot be before the From date.");
|
||||
}
|
||||
final StringBuilder csv = new StringBuilder("Txn Id,Date,Type,Amount,Running Balance,Reference,Hold / Block Reason,Reversed,Entered At\n");
|
||||
for (final Map<String, Object> r : readService.statement(holdId, from, to)) {
|
||||
csv.append(String.join(",", cell(r.get("id")), cell(r.get("transaction_date")), cell(typeName(r.get("transaction_type_enum"))),
|
||||
cell(r.get("amount")), cell(r.get("running_balance_derived")), cell(r.get("ref_no")), cell(r.get("reason_for_block")),
|
||||
cell(r.get("is_reversed")), cell(r.get("created_date")))).append('\n');
|
||||
}
|
||||
return csvResponse(csv.toString(), "statement-hold-" + holdId + "-" + from + "-to-" + to + ".csv");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("documents")
|
||||
public String listDocuments(@QueryParam("ownerType") final String ownerType, @QueryParam("ownerId") final Long ownerId) {
|
||||
reader();
|
||||
return gson.toJson(documents.list(ownerType, ownerId));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("documents")
|
||||
@Operation(summary = "Attach a PDF / JPEG / PNG (Base64, at most 5 MB) to a HOLD, GRIEVANCE or RESTORATION")
|
||||
public String uploadDocument(final String body) {
|
||||
final JsonObject json = parse(body);
|
||||
final String owner = text(json, "ownerType") == null ? "" : text(json, "ownerType").toUpperCase(java.util.Locale.ROOT);
|
||||
final AppUser user = user(switch (owner) {
|
||||
case "GRIEVANCE" -> GRIEVANCE;
|
||||
case "RESTORATION" -> RESTORATION;
|
||||
default -> REQUEST_ACTION;
|
||||
});
|
||||
final Long ownerId = json.has("ownerId") && !json.get("ownerId").isJsonNull() ? json.get("ownerId").getAsLong() : null;
|
||||
return created(documents.upload(owner, ownerId, text(json, "docType"), text(json, "fileName"), text(json, "contentType"),
|
||||
text(json, "content"), text(json, "remarks"), user.getDisplayName()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("documents/{id}/content")
|
||||
@Produces(MediaType.APPLICATION_OCTET_STREAM)
|
||||
public Response downloadDocument(@PathParam("id") final Long id) {
|
||||
reader();
|
||||
final StoredFile file = documents.download(id);
|
||||
return Response.ok(file.content(), file.contentType())
|
||||
.header("Content-Disposition", "attachment; filename=\"" + file.fileName().replace("\"", "") + "\"")
|
||||
.header("X-Content-Type-Options", "nosniff").build();
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Savings transaction types as the statement shows them; anything else by number. */
|
||||
private static String typeName(final Object type) {
|
||||
if (type == null) {
|
||||
return "";
|
||||
}
|
||||
return switch (((Number) type).intValue()) {
|
||||
case 1 -> "Deposit";
|
||||
case 2 -> "Withdrawal";
|
||||
case 3 -> "Interest posting";
|
||||
case 4 -> "Withdrawal fee";
|
||||
case 5 -> "Annual fee";
|
||||
case 7 -> "Charge";
|
||||
case 12 -> "Transfer initiated";
|
||||
case 13 -> "Transfer approved";
|
||||
case 17 -> "Overdraft interest";
|
||||
case 19 -> "Withholding tax";
|
||||
case 20 -> "Amount hold";
|
||||
case 21 -> "Hold release";
|
||||
default -> "Type " + type;
|
||||
};
|
||||
}
|
||||
|
||||
private static String cell(final Object value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
final String s = String.valueOf(value);
|
||||
// Spreadsheet formula injection guard, then CSV quoting.
|
||||
final String safe = !s.isEmpty() && "=+-@".indexOf(s.charAt(0)) >= 0 && !s.matches("-?\\d+(\\.\\d+)?") ? "'" + s : s;
|
||||
return "\"" + safe.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
private static Response csvResponse(final String csv, final String fileName) {
|
||||
return Response.ok(csv.getBytes(StandardCharsets.UTF_8), "text/csv; charset=UTF-8")
|
||||
.header("Content-Disposition", "attachment; filename=\"" + fileName + "\"").build();
|
||||
}
|
||||
|
||||
private void reader() {
|
||||
context.authenticatedUser().validateHasReadPermission(READ);
|
||||
}
|
||||
|
||||
private AppUser user(final String permission) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo(permission);
|
||||
return user;
|
||||
}
|
||||
|
||||
private String created(final Long id) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("resourceId", id);
|
||||
return gson.toJson(out);
|
||||
}
|
||||
|
||||
private static Map<String, Object> fields(final String body) {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (final Map.Entry<String, JsonElement> e : parse(body).entrySet()) {
|
||||
final JsonElement v = e.getValue();
|
||||
out.put(e.getKey(), v == null || v.isJsonNull() ? null : v.getAsString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static JsonObject parse(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return new JsonObject();
|
||||
}
|
||||
try {
|
||||
final JsonElement e = JsonParser.parseString(body);
|
||||
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
|
||||
} catch (final RuntimeException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.body.invalid", "The request body is not valid JSON.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
private static Boolean bool(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsBoolean() : null;
|
||||
}
|
||||
|
||||
private static BigDecimal decimal(final JsonObject json, final String key) {
|
||||
final String v = text(json, key);
|
||||
return v == null || v.isBlank() ? null : number(v, key);
|
||||
}
|
||||
|
||||
private static BigDecimal number(final String value, final String key) {
|
||||
try {
|
||||
return new BigDecimal(value.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.number.invalid", key + " must be a number.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate date(final JsonObject json, final String key) {
|
||||
final String v = text(json, key);
|
||||
return v == null || v.isBlank() ? null : parseDate(v, key);
|
||||
}
|
||||
|
||||
private static LocalDate parseDate(final String value, final String key) {
|
||||
try {
|
||||
return LocalDate.parse(value.trim());
|
||||
} catch (final DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.date.invalid", key + " must be a date in yyyy-MM-dd form.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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.api;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.crypto.I4cCryptoJsAes;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cAcks;
|
||||
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.I4cInboundAuthService;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRequestService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The two endpoints I4C calls on the bank (I4C-MHA Banks API Integration Specification 2.0):
|
||||
* <ul>
|
||||
* <li>{@code POST /v1/i4c/auth/token} - section 2, "Authenticate from I4C-MHA to BANKS": username/password in, an
|
||||
* {@code access_token} out, or 401 {@code {"message":"You are not authorized to access"}}.</li>
|
||||
* <li>{@code POST /v1/i4c/complaints} - section 4, the complaint push, answered with the section 5 ack: response code
|
||||
* 00 and the bank's {@code job_id}, or 11 "Structure validation failed". The bank's findings follow later as a separate
|
||||
* call to I4C (section 6).</li>
|
||||
* </ul>
|
||||
* Both are outside the Fineract login (see SecurityConfig) and authenticate the caller here: token, expiry and source
|
||||
* IP. Every call, accepted or refused, is written to the I4C API log.
|
||||
*/
|
||||
@Slf4j
|
||||
@Path("/v1/i4c")
|
||||
@Component
|
||||
@Tag(name = "I4C CFCFRMS (bank side, called by I4C)", description = "Endpoints the I4C-MHA NCRP-CFCFRMS platform calls on the bank.")
|
||||
@RequiredArgsConstructor
|
||||
public class I4cInboundApiResource {
|
||||
|
||||
private static final String NOT_AUTHORIZED = "You are not authorized to access";
|
||||
private static final int MAX_BODY_BYTES = 1_000_000;
|
||||
/** Requests per source IP per minute; far above any real I4C rate, low enough to blunt a flood. */
|
||||
private static final int RATE_LIMIT_PER_MINUTE = 600;
|
||||
|
||||
private final I4cProperties properties;
|
||||
private final I4cInboundAuthService authService;
|
||||
private final I4cRequestService requestService;
|
||||
private final I4cApiLogService apiLog;
|
||||
private final Map<String, long[]> rateWindows = new ConcurrentHashMap<>();
|
||||
|
||||
@POST
|
||||
@Path("auth/token")
|
||||
@Consumes({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_FORM_URLENCODED, MediaType.TEXT_PLAIN })
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "I4C obtains an access token (specification section 2): JSON username/password, client_id/client_secret,"
|
||||
+ " a form body, or an Authorization: Basic header")
|
||||
public Response token(final String body, @HeaderParam("Authorization") final String authorization,
|
||||
@Context final HttpServletRequest http) {
|
||||
final long start = System.nanoTime();
|
||||
final String ip = authService.clientIp(http);
|
||||
final String endpoint = "POST /v1/i4c/auth/token";
|
||||
final Response refusal = gate(ip, body);
|
||||
if (refusal != null) {
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, refusal.getStatus(), null, null, elapsed(start), ip, body, null,
|
||||
"refused at gate");
|
||||
return refusal;
|
||||
}
|
||||
// Credentials, in the order the specification and OAuth 2.0 allow: an Authorization: Basic header
|
||||
// (client_id:client_secret), then the body as JSON (username/password or client_id/client_secret), then as a form.
|
||||
String[] credentials = I4cInboundAuthService.basicCredentials(authorization);
|
||||
if (credentials == null) {
|
||||
credentials = credentialsFromBody(body);
|
||||
}
|
||||
final Optional<I4cInboundAuthService.IssuedToken> token = credentials == null ? Optional.empty()
|
||||
: authService.issueToken(credentials[0], credentials[1], ip);
|
||||
if (token.isEmpty()) {
|
||||
final String out = message(NOT_AUTHORIZED);
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, 401, I4cCodes.UNAUTHORIZED, null, elapsed(start), ip, body, out,
|
||||
"invalid credentials");
|
||||
return json(401, out);
|
||||
}
|
||||
final JsonObject out = new JsonObject();
|
||||
out.addProperty("access_token", token.get().accessToken());
|
||||
out.addProperty("token_type", "Bearer");
|
||||
out.addProperty("expires_in", token.get().expiresInSeconds());
|
||||
final String outText = wrap(out.toString());
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, 200, I4cCodes.ACK_SUCCESS, null, elapsed(start), ip, body, out.toString(), null);
|
||||
return json(200, outText);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("complaints")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "I4C pushes a cyber-fraud complaint (specification sections 4 and 5); authenticated by Bearer token, Basic"
|
||||
+ " header, or username/password inside the message")
|
||||
public Response complaint(final String body, @HeaderParam("Authorization") final String authorization,
|
||||
@Context final HttpServletRequest http) {
|
||||
final long start = System.nanoTime();
|
||||
final String ip = authService.clientIp(http);
|
||||
final String endpoint = "POST /v1/i4c/complaints";
|
||||
final Response refusal = gate(ip, body);
|
||||
if (refusal != null) {
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, refusal.getStatus(), null, null, elapsed(start), ip, body, null,
|
||||
"refused at gate");
|
||||
return refusal;
|
||||
}
|
||||
|
||||
// Read first: the message may carry its own credentials (section 2). An unreadable body is reported only to a
|
||||
// caller that has authenticated some other way.
|
||||
JsonObject json = null;
|
||||
RuntimeException bodyError = null;
|
||||
try {
|
||||
json = unwrap(body);
|
||||
} catch (final RuntimeException e) {
|
||||
bodyError = e;
|
||||
}
|
||||
final I4cProperties.AuthMode mode = authService.authenticatePush(authorization, json);
|
||||
if (mode == null) {
|
||||
final String out = message(NOT_AUTHORIZED);
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, 401, I4cCodes.UNAUTHORIZED, null, elapsed(start), ip, body, out,
|
||||
"no valid token, Basic credentials or message credentials");
|
||||
return json(401, out);
|
||||
}
|
||||
if (bodyError != null) {
|
||||
final boolean crypto = bodyError instanceof I4cCryptoJsAes.I4cCryptoException;
|
||||
final String code = crypto ? I4cCodes.DECRYPTION_ERROR : I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED;
|
||||
final String out = I4cAcks.ack(code, null, null);
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, 400, code, null, elapsed(start), ip, crypto ? null : body, out,
|
||||
crypto ? bodyError.getMessage() : "body is not a JSON object");
|
||||
return json(400, wrap(out));
|
||||
}
|
||||
|
||||
final I4cComplaintParser.Result parsed = I4cComplaintParser.parse(json, DateUtils.getBusinessLocalDate(),
|
||||
properties.getAck().getMaxFutureDays());
|
||||
if (!parsed.isValid()) {
|
||||
final String ackNo = text(json, "acknowledgement_no");
|
||||
final String code = properties.getAck().getCodeStyle() == I4cProperties.AckCodeStyle.SECTION7 ? parsed.section7Code()
|
||||
: I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED;
|
||||
final String out = I4cAcks.ack(code, ackNo, "BANKS-" + UUID.randomUUID());
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, 400, code, ackNo, elapsed(start), ip, json.toString(), out,
|
||||
String.join("; ", parsed.errors()));
|
||||
return json(400, wrap(out));
|
||||
}
|
||||
|
||||
final String ackNo = parsed.complaint().acknowledgementNo();
|
||||
try {
|
||||
final I4cRequestService.Accepted accepted = requestService.accept(parsed.complaint(), json.toString(), ip);
|
||||
final boolean refuseDuplicate = accepted.duplicate()
|
||||
&& properties.getAck().getDuplicateMode() == I4cProperties.DuplicateMode.CODE_50003;
|
||||
final String code = refuseDuplicate ? I4cCodes.DUPLICATE_TRANSACTION : I4cCodes.ACK_SUCCESS;
|
||||
final String out = I4cAcks.ack(code, ackNo, accepted.jobId());
|
||||
final int status = refuseDuplicate ? 409 : 200;
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, status, code, ackNo, elapsed(start), ip, json.toString(), out,
|
||||
(accepted.duplicate() ? "repeat push, original job_id returned; " : "") + "auth " + mode);
|
||||
return json(status, wrap(out));
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("I4C complaint {} could not be stored", ackNo, e);
|
||||
final String out = I4cAcks.ack(I4cCodes.SERVICE_UNAVAILABLE, ackNo, null);
|
||||
apiLog.record(I4cApiLogService.IN, endpoint, 503, I4cCodes.SERVICE_UNAVAILABLE, ackNo, elapsed(start), ip, json.toString(), out,
|
||||
e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
return json(503, wrap(out));
|
||||
}
|
||||
}
|
||||
|
||||
/** username/password or client_id/client_secret from a JSON (possibly encrypted-envelope) or form body. */
|
||||
private String[] credentialsFromBody(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (body.trim().startsWith("{")) {
|
||||
return I4cInboundAuthService.credentialsIn(unwrap(body));
|
||||
}
|
||||
final JsonObject form = new JsonObject();
|
||||
for (final String pair : body.split("&")) {
|
||||
final int eq = pair.indexOf('=');
|
||||
if (eq > 0) {
|
||||
form.addProperty(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8),
|
||||
URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
return I4cInboundAuthService.credentialsIn(form);
|
||||
} catch (final RuntimeException e) {
|
||||
log.debug("I4C token body unreadable: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration switched off, caller not on the IP allowlist, body too large, or too many calls: refused before any
|
||||
* work.
|
||||
*/
|
||||
private Response gate(final String ip, final String body) {
|
||||
if (!properties.isEnabled()) {
|
||||
return json(503, I4cAcks.ack(I4cCodes.MAINTENANCE, null, null));
|
||||
}
|
||||
if (!authService.isIpAllowed(ip)) {
|
||||
log.warn("I4C call refused from non-allowlisted IP {}", ip);
|
||||
return json(403, message(NOT_AUTHORIZED));
|
||||
}
|
||||
if (body != null && body.getBytes(StandardCharsets.UTF_8).length > MAX_BODY_BYTES) {
|
||||
return json(413, I4cAcks.ack(I4cCodes.REQUEST_TOO_LARGE, null, null));
|
||||
}
|
||||
if (!withinRateLimit(ip)) {
|
||||
return json(429, I4cAcks.ack(I4cCodes.RATE_LIMIT, null, null));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean withinRateLimit(final String ip) {
|
||||
final long minute = System.currentTimeMillis() / 60_000L;
|
||||
final long[] window = rateWindows.compute(ip == null ? "-" : ip, (k, w) -> {
|
||||
if (w == null || w[0] != minute) {
|
||||
return new long[] { minute, 1 };
|
||||
}
|
||||
w[1]++;
|
||||
return w;
|
||||
});
|
||||
if (rateWindows.size() > 10_000) {
|
||||
rateWindows.entrySet().removeIf(e -> e.getValue()[0] != minute);
|
||||
}
|
||||
return window[1] <= RATE_LIMIT_PER_MINUTE;
|
||||
}
|
||||
|
||||
private JsonObject unwrap(final String body) {
|
||||
final JsonElement element = JsonParser.parseString(body == null ? "" : body);
|
||||
if (!element.isJsonObject()) {
|
||||
throw new IllegalArgumentException("not a JSON object");
|
||||
}
|
||||
final JsonObject object = element.getAsJsonObject();
|
||||
final I4cProperties.Crypto crypto = properties.getCrypto();
|
||||
if (crypto.getMode() == I4cProperties.CryptoMode.ENVELOPE && object.has("data") && object.get("data").isJsonPrimitive()
|
||||
&& I4cCryptoJsAes.looksEncrypted(object.get("data").getAsString())) {
|
||||
if (!crypto.hasPassphrase()) {
|
||||
throw new I4cCryptoJsAes.I4cCryptoException("fineract.i4c.crypto.passphrase is not set", null);
|
||||
}
|
||||
final JsonElement inner = JsonParser
|
||||
.parseString(I4cCryptoJsAes.decrypt(object.get("data").getAsString(), crypto.getPassphrase()));
|
||||
if (!inner.isJsonObject()) {
|
||||
throw new IllegalArgumentException("decrypted body is not a JSON object");
|
||||
}
|
||||
return inner.getAsJsonObject();
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
private String wrap(final String json) {
|
||||
final I4cProperties.Crypto crypto = properties.getCrypto();
|
||||
if (crypto.getMode() == I4cProperties.CryptoMode.ENVELOPE && crypto.hasPassphrase()) {
|
||||
final JsonObject envelope = new JsonObject();
|
||||
envelope.addProperty("data", I4cCryptoJsAes.encrypt(json, crypto.getPassphrase()));
|
||||
return envelope.toString();
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
private static String message(final String text) {
|
||||
final JsonObject out = new JsonObject();
|
||||
out.addProperty("message", text);
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private static Response json(final int status, final String body) {
|
||||
return Response.status(status).type(MediaType.APPLICATION_JSON).entity(body).build();
|
||||
}
|
||||
|
||||
private static String text(final JsonObject json, final String key) {
|
||||
return json.has(key) && json.get(key).isJsonPrimitive() ? json.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
private static long elapsed(final long startNanos) {
|
||||
return (System.nanoTime() - startNanos) / 1_000_000L;
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Settings for the I4C (NCRP-CFCFRMS) integration, {@code fineract.i4c.*} in application.properties. Every credential
|
||||
* is read from the environment; see the comments there for what each one means.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fineract.i4c")
|
||||
@Getter
|
||||
@Setter
|
||||
public class I4cProperties {
|
||||
|
||||
private boolean enabled;
|
||||
private String tenantIdentifier;
|
||||
private int bankCode;
|
||||
private String bankName;
|
||||
private Inbound inbound = new Inbound();
|
||||
private Crypto crypto = new Crypto();
|
||||
private Outbound outbound = new Outbound();
|
||||
private Match match = new Match();
|
||||
private Hold hold = new Hold();
|
||||
private Restoration restoration = new Restoration();
|
||||
private Simulator simulator = new Simulator();
|
||||
private Ack ack = new Ack();
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Inbound {
|
||||
|
||||
private String username;
|
||||
private String passwordHash;
|
||||
private String password;
|
||||
private long tokenTtlSeconds = 900;
|
||||
private String allowedIps;
|
||||
private boolean trustForwardedFor;
|
||||
/** Name the bank gives I4C for this client (specification: "client name, client ID, and client secret key"). */
|
||||
private String clientName;
|
||||
/**
|
||||
* How a complaint push may prove who sent it, comma-separated: TOKEN (Bearer from /auth/token), BASIC (Authorization:
|
||||
* Basic user:password), BODY (username / password inside the JSON message, as the specification's section 2 describes
|
||||
* for "every request and response message"). Every mode checks the same credentials and the same IP allowlist.
|
||||
*/
|
||||
private String authModes = "TOKEN,BASIC,BODY";
|
||||
|
||||
public boolean allows(final AuthMode mode) {
|
||||
return authModes == null || authModes.isBlank() || Arrays.stream(authModes.split(",")).map(String::trim)
|
||||
.anyMatch(m -> m.equalsIgnoreCase(mode.name()));
|
||||
}
|
||||
|
||||
public List<String> allowedIpList() {
|
||||
if (allowedIps == null || allowedIps.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.stream(allowedIps.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Crypto {
|
||||
|
||||
private CryptoMode mode = CryptoMode.FIELDS;
|
||||
private String passphrase;
|
||||
|
||||
public boolean hasPassphrase() {
|
||||
return passphrase != null && !passphrase.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public enum CryptoMode {
|
||||
NONE, FIELDS, ENVELOPE
|
||||
}
|
||||
|
||||
public enum AuthMode {
|
||||
TOKEN, BASIC, BODY
|
||||
}
|
||||
|
||||
/**
|
||||
* Which code set the ack uses when a push is refused. SECTION5: always 11 "Structure validation failed", as section 5
|
||||
* shows. SECTION7: the specific section 7 code (40001 invalid account number, 40004 invalid amount, 40005 invalid date),
|
||||
* falling back to 11.
|
||||
*/
|
||||
public enum AckCodeStyle {
|
||||
SECTION5, SECTION7
|
||||
}
|
||||
|
||||
/** A repeated push: SAME_JOB answers 00 with the original job_id; CODE_50003 answers 50003 "Duplicate Transaction". */
|
||||
public enum DuplicateMode {
|
||||
SAME_JOB, CODE_50003
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Ack {
|
||||
|
||||
private AckCodeStyle codeStyle = AckCodeStyle.SECTION5;
|
||||
private DuplicateMode duplicateMode = DuplicateMode.SAME_JOB;
|
||||
/** Refuse a transaction_date more than this many days in the future (section 7: 40005 "Invalid Date"). */
|
||||
private int maxFutureDays = 1;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Outbound {
|
||||
|
||||
private String baseUrl;
|
||||
private String authPath = "/auth/token";
|
||||
private String responsePath = "/bank/response";
|
||||
/**
|
||||
* Section 5 calls the ack an "Ack Request Message from BANKS to I4C-MHA". It is always returned as the response to the
|
||||
* push; when I4C also wants it as a call of its own, this is its path. Blank: no separate ack call.
|
||||
*/
|
||||
private String ackPath;
|
||||
private String username;
|
||||
private String password;
|
||||
private int timeoutMs = 15000;
|
||||
private int maxAttempts = 8;
|
||||
|
||||
public boolean isConfigured() {
|
||||
return baseUrl != null && !baseUrl.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Match {
|
||||
|
||||
private int dateWindowDays = 2;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Hold {
|
||||
|
||||
private int reviewDays = 90;
|
||||
private int intimationHours = 24;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Restoration {
|
||||
|
||||
/** 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");
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Simulator {
|
||||
|
||||
private boolean enabled;
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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.crypto;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* AES-256 in the format the I4C specification's samples are written in.
|
||||
*
|
||||
* The specification's encrypted username/password samples begin {@code U2FsdGVkX1}, which is Base64 for the 8 bytes
|
||||
* {@code Salted__}: the format OpenSSL's {@code enc -aes-256-cbc -md md5} and CryptoJS's {@code AES.encrypt(text,
|
||||
* passphrase)} both produce. The layout is {@code "Salted__" + 8-byte salt + ciphertext}, Base64 encoded; the 32-byte
|
||||
* key and 16-byte IV come from the passphrase and salt through OpenSSL's EVP_BytesToKey with MD5 and one iteration; the
|
||||
* cipher is AES/CBC with PKCS#7 padding (PKCS5Padding in the JCE).
|
||||
*
|
||||
* MD5 and a single iteration are weak key derivation, but they are what the counterparty uses; the transport is TLS
|
||||
* over a whitelisted connection, and this layer only has to match it.
|
||||
*/
|
||||
public final class I4cCryptoJsAes {
|
||||
|
||||
private static final byte[] SALTED_MAGIC = "Salted__".getBytes(StandardCharsets.US_ASCII);
|
||||
private static final int SALT_LENGTH = 8;
|
||||
private static final int KEY_LENGTH = 32;
|
||||
private static final int IV_LENGTH = 16;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private I4cCryptoJsAes() {}
|
||||
|
||||
/** True for text that is in the {@code Salted__} format (Base64 starting {@code U2FsdGVkX1}). */
|
||||
public static boolean looksEncrypted(final String value) {
|
||||
return value != null && value.startsWith("U2FsdGVkX1");
|
||||
}
|
||||
|
||||
public static String encrypt(final String plainText, final String passphrase) {
|
||||
final byte[] salt = new byte[SALT_LENGTH];
|
||||
RANDOM.nextBytes(salt);
|
||||
return encrypt(plainText, passphrase, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts with the given salt; only tests need a fixed salt, everything else uses
|
||||
* {@link #encrypt(String, String)}.
|
||||
*/
|
||||
static String encrypt(final String plainText, final String passphrase, final byte[] salt) {
|
||||
try {
|
||||
final byte[][] keyAndIv = evpBytesToKey(passphrase.getBytes(StandardCharsets.UTF_8), salt);
|
||||
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyAndIv[0], "AES"), new IvParameterSpec(keyAndIv[1]));
|
||||
final byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||
final byte[] out = new byte[SALTED_MAGIC.length + SALT_LENGTH + cipherText.length];
|
||||
System.arraycopy(SALTED_MAGIC, 0, out, 0, SALTED_MAGIC.length);
|
||||
System.arraycopy(salt, 0, out, SALTED_MAGIC.length, SALT_LENGTH);
|
||||
System.arraycopy(cipherText, 0, out, SALTED_MAGIC.length + SALT_LENGTH, cipherText.length);
|
||||
return Base64.getEncoder().encodeToString(out);
|
||||
} catch (final GeneralSecurityException e) {
|
||||
throw new I4cCryptoException("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String decrypt(final String base64CipherText, final String passphrase) {
|
||||
final byte[] all;
|
||||
try {
|
||||
all = Base64.getDecoder().decode(base64CipherText.trim());
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw new I4cCryptoException("Encrypted value is not valid Base64", e);
|
||||
}
|
||||
if (all.length <= SALTED_MAGIC.length + SALT_LENGTH
|
||||
|| !Arrays.equals(Arrays.copyOfRange(all, 0, SALTED_MAGIC.length), SALTED_MAGIC)) {
|
||||
throw new I4cCryptoException("Encrypted value is not in the Salted__ format", null);
|
||||
}
|
||||
final byte[] salt = Arrays.copyOfRange(all, SALTED_MAGIC.length, SALTED_MAGIC.length + SALT_LENGTH);
|
||||
final byte[] cipherText = Arrays.copyOfRange(all, SALTED_MAGIC.length + SALT_LENGTH, all.length);
|
||||
try {
|
||||
final byte[][] keyAndIv = evpBytesToKey(passphrase.getBytes(StandardCharsets.UTF_8), salt);
|
||||
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyAndIv[0], "AES"), new IvParameterSpec(keyAndIv[1]));
|
||||
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
|
||||
} catch (final GeneralSecurityException e) {
|
||||
// A wrong passphrase almost always surfaces here as a padding failure.
|
||||
throw new I4cCryptoException("Decryption failed (wrong passphrase or corrupted value)", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenSSL EVP_BytesToKey with MD5, one iteration: D_i = MD5(D_{i-1} || password || salt), concatenated. */
|
||||
private static byte[][] evpBytesToKey(final byte[] password, final byte[] salt) throws GeneralSecurityException {
|
||||
final MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
final byte[] derived = new byte[KEY_LENGTH + IV_LENGTH];
|
||||
byte[] previous = new byte[0];
|
||||
int filled = 0;
|
||||
while (filled < derived.length) {
|
||||
md5.reset();
|
||||
md5.update(previous);
|
||||
md5.update(password);
|
||||
md5.update(salt);
|
||||
previous = md5.digest();
|
||||
final int n = Math.min(previous.length, derived.length - filled);
|
||||
System.arraycopy(previous, 0, derived, filled, n);
|
||||
filled += n;
|
||||
}
|
||||
return new byte[][] { Arrays.copyOfRange(derived, 0, KEY_LENGTH), Arrays.copyOfRange(derived, KEY_LENGTH, derived.length) };
|
||||
}
|
||||
|
||||
public static final class I4cCryptoException extends RuntimeException {
|
||||
|
||||
public I4cCryptoException(final String message, final Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.cfcfrms.i4c.data;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.Map;
|
||||
|
||||
/** The section 5 ack message, and the wording of each code the bank answers a push with. */
|
||||
public final class I4cAcks {
|
||||
|
||||
private I4cAcks() {}
|
||||
|
||||
/** Section 7's response messages for the codes an ack can carry. */
|
||||
private static final Map<String, String> MESSAGES = Map.ofEntries(Map.entry(I4cCodes.ACK_SUCCESS, "Success"),
|
||||
Map.entry(I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED, "Structure validation failed"),
|
||||
Map.entry(I4cCodes.INVALID_ACCOUNT_NUMBER, "Invalid Account Number"), Map.entry(I4cCodes.INVALID_AMOUNT, "Invalid Transaction Amount"),
|
||||
Map.entry(I4cCodes.INVALID_DATE, "Invalid Date"), Map.entry(I4cCodes.DUPLICATE_TRANSACTION, "Duplicate Transaction"),
|
||||
Map.entry(I4cCodes.SERVICE_UNAVAILABLE, "Service Unavailable"), Map.entry(I4cCodes.DECRYPTION_ERROR, "Encryption/Decryption Error"),
|
||||
Map.entry(I4cCodes.MAINTENANCE, "Service Temporarily Unavailable"), Map.entry(I4cCodes.REQUEST_TOO_LARGE, "Request Size Too Large"),
|
||||
Map.entry(I4cCodes.RATE_LIMIT, "Rate Limit Exceeded"));
|
||||
|
||||
public static String messageFor(final String code) {
|
||||
return MESSAGES.getOrDefault(code, "Unmapped Exception");
|
||||
}
|
||||
|
||||
/** {@code {"meta":{"response_code","response_message"},"data":{"acknowledgement_no","job_id"}}}. */
|
||||
public static String ack(final String code, final String acknowledgementNo, final String jobId) {
|
||||
final JsonObject meta = new JsonObject();
|
||||
meta.addProperty("response_code", code);
|
||||
meta.addProperty("response_message", messageFor(code));
|
||||
final JsonObject data = new JsonObject();
|
||||
if (acknowledgementNo != null) {
|
||||
data.addProperty("acknowledgement_no", acknowledgementNo);
|
||||
}
|
||||
if (jobId != null) {
|
||||
data.addProperty("job_id", jobId);
|
||||
}
|
||||
final JsonObject out = new JsonObject();
|
||||
out.add("meta", meta);
|
||||
out.add("data", data);
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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.data;
|
||||
|
||||
/** Codes fixed by the I4C-MHA Banks API Integration Specification 2.0. */
|
||||
public final class I4cCodes {
|
||||
|
||||
private I4cCodes() {}
|
||||
|
||||
/** Section 5: ack codes. */
|
||||
public static final String ACK_SUCCESS = "00";
|
||||
public static final String ACK_STRUCTURE_VALIDATION_FAILED = "11";
|
||||
|
||||
/** Section 6 / 7: per-transaction status_code in the response. */
|
||||
public static final String STATUS_SUCCESS = "00";
|
||||
public static final String STATUS_RECORD_NOT_FOUND = "01";
|
||||
public static final String STATUS_INVALID_RRN = "02";
|
||||
|
||||
/** Section 7 code mapping, used in remarks and in the API log. */
|
||||
public static final String INVALID_ACCOUNT_NUMBER = "40001";
|
||||
public static final String ACCOUNT_NOT_FOUND = "40002";
|
||||
public static final String INVALID_AMOUNT = "40004";
|
||||
public static final String INVALID_DATE = "40005";
|
||||
public static final String UNAUTHORIZED = "40101";
|
||||
public static final String SESSION_EXPIRED = "40102";
|
||||
public static final String FORBIDDEN = "40301";
|
||||
public static final String INSUFFICIENT_BALANCE = "50001";
|
||||
public static final String LIEN_EXISTS = "50002";
|
||||
public static final String DUPLICATE_TRANSACTION = "50003";
|
||||
public static final String RRN_MISMATCH = "50006";
|
||||
public static final String SERVICE_UNAVAILABLE = "60002";
|
||||
public static final String DECRYPTION_ERROR = "60015";
|
||||
public static final String MAINTENANCE = "70004";
|
||||
public static final String REQUEST_TOO_LARGE = "70005";
|
||||
public static final String RATE_LIMIT = "70006";
|
||||
public static final String UNMAPPED = "70010";
|
||||
|
||||
/** Section 6: txn_type / txn_type_id pairs, spelt exactly as the specification prints them. */
|
||||
public enum TxnType {
|
||||
|
||||
PUT_ON_HOLD(1, "Transaction Put on Hold"), //
|
||||
MONEY_TRANSFER_TO(3, "Money Transfer To"), //
|
||||
WITHDRAWAL_ATM(5, "Withdrawal through ATM"), //
|
||||
WITHDRAWAL_AEPS(6, "Withdrawal through AEPS"), //
|
||||
WITHDRAWAL_POS(11, "Withdrawal through POS"), //
|
||||
WITHDRAWAL_CHEQUE(14, "Withdrawal through Cheque"), //
|
||||
OTHERS_UPTO_500(17, "Others Less than equal to 500");
|
||||
|
||||
private final int id;
|
||||
private final String label;
|
||||
|
||||
TxnType(final int id, final String label) {
|
||||
this.id = id;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public int id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One complaint push from I4C (specification section 4, "Request Message from I4C-MHA to BANKS"), as parsed: the
|
||||
* instrument fields plus its incidents.
|
||||
*/
|
||||
public record I4cComplaint(String acknowledgementNo, String subCategory, String requestor, String payerBank, String payerBankCode,
|
||||
String modeOfPayment, String payerMobileNumber, String payerAccountNumber, String state, String district, String transactionType,
|
||||
String wallet, List<Incident> incidents) {
|
||||
|
||||
/** One entry of {@code incidents[]}. Card fields are present only for the card-fraud sub-categories. */
|
||||
public record Incident(int seqNo, BigDecimal amount, String rrn, LocalDate transactionDate, String transactionTime,
|
||||
BigDecimal disputedAmount, int layer, String cardFirst6, String cardLast4, String cardLength) {
|
||||
}
|
||||
|
||||
public int maxLayer() {
|
||||
return incidents.stream().mapToInt(Incident::layer).max().orElse(0);
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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.data;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Reads an I4C complaint push.
|
||||
*
|
||||
* Liberal on purpose. The specification's own samples are not consistent: {@code mode_of _payment} has a space in the
|
||||
* key, {@code acknowledment_no} is misspelt in places, {@code incidents} appears both beside and inside
|
||||
* {@code instrument}, amounts and layers come as strings or numbers, and dates as {@code dd-MM-yyyy} in the tables but
|
||||
* {@code yyyy-MM-dd} in the samples. Keys are therefore matched after dropping case, spaces and underscores, and values
|
||||
* are coerced. Rejecting a real fraud complaint over a key spelling would defeat the point of the integration.
|
||||
*
|
||||
* Strict only on what the bank needs to act: the acknowledgement number, the sub-category, the account, and per
|
||||
* incident the amount, disputed amount, RRN, date and layer. Anything missing there is a structure validation failure
|
||||
* (ack code 11).
|
||||
*/
|
||||
public final class I4cComplaintParser {
|
||||
|
||||
private static final List<DateTimeFormatter> DATE_FORMATS = List.of(DateTimeFormatter.ISO_LOCAL_DATE,
|
||||
DateTimeFormatter.ofPattern("dd-MM-yyyy"), DateTimeFormatter.ofPattern("dd/MM/yyyy"), DateTimeFormatter.ofPattern("yyyy/MM/dd"),
|
||||
DateTimeFormatter.ofPattern("dd-MM-yy"));
|
||||
|
||||
private static final int MAX_INCIDENTS = 200;
|
||||
|
||||
private I4cComplaintParser() {}
|
||||
|
||||
/**
|
||||
* {@code codes} holds the section 7 code of each kind of fault found (40001 account number, 40004 amount, 40005
|
||||
* date); a fault with no specific section 7 code adds nothing, and the ack then falls back to 11.
|
||||
*/
|
||||
public record Result(I4cComplaint complaint, List<String> errors, Set<String> codes) {
|
||||
|
||||
public boolean isValid() {
|
||||
return errors.isEmpty();
|
||||
}
|
||||
|
||||
/** The most specific section 7 code for the faults found, in the table's order; 11 when none applies. */
|
||||
public String section7Code() {
|
||||
for (final String code : List.of(I4cCodes.INVALID_ACCOUNT_NUMBER, I4cCodes.INVALID_AMOUNT, I4cCodes.INVALID_DATE)) {
|
||||
if (codes.contains(code)) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
return I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
public static Result parse(final JsonObject root) {
|
||||
return parse(root, null, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param today
|
||||
* the bank's business date; a transaction_date more than {@code maxFutureDays} after it is refused
|
||||
* (section 7: 40005 "Invalid Date"). Null skips the check.
|
||||
*/
|
||||
public static Result parse(final JsonObject root, final LocalDate today, final int maxFutureDays) {
|
||||
final List<String> errors = new ArrayList<>();
|
||||
final Set<String> codes = new LinkedHashSet<>();
|
||||
final Map<String, JsonElement> top = normalise(root);
|
||||
final JsonObject instrumentObject = objectOrNull(top.get("instrument"));
|
||||
final Map<String, JsonElement> instrument = instrumentObject == null ? Map.of() : normalise(instrumentObject);
|
||||
|
||||
final String ack = firstText(top, instrument, "acknowledgementno", "acknowledmentno", "acknowledgmentno", "ackno");
|
||||
if (ack == null) {
|
||||
errors.add("acknowledgement_no is required");
|
||||
} else if (!ack.matches("[A-Za-z0-9-]{1,40}")) {
|
||||
errors.add("acknowledgement_no must be 1-40 letters/digits");
|
||||
}
|
||||
final String subCategory = firstText(top, instrument, "subcategory");
|
||||
if (subCategory == null) {
|
||||
errors.add("sub_category is required");
|
||||
}
|
||||
final String payerAccount = firstText(top, instrument, "payeraccountnumber", "accountnumber");
|
||||
if (payerAccount == null) {
|
||||
errors.add("instrument.payer_account_number is required");
|
||||
} else if (!payerAccount.matches("[A-Za-z0-9@,.\\-]{1,40}")) {
|
||||
errors.add("instrument.payer_account_number has characters outside a-z A-Z 0-9 @ , . -");
|
||||
codes.add(I4cCodes.INVALID_ACCOUNT_NUMBER);
|
||||
}
|
||||
|
||||
JsonElement incidentsElement = top.get("incidents");
|
||||
if (incidentsElement == null) {
|
||||
incidentsElement = instrument.get("incidents");
|
||||
}
|
||||
final List<I4cComplaint.Incident> incidents = new ArrayList<>();
|
||||
if (incidentsElement == null || !incidentsElement.isJsonArray() || incidentsElement.getAsJsonArray().isEmpty()) {
|
||||
errors.add("incidents must be a non-empty array");
|
||||
} else {
|
||||
final JsonArray array = incidentsElement.getAsJsonArray();
|
||||
if (array.size() > MAX_INCIDENTS) {
|
||||
errors.add("incidents has more than " + MAX_INCIDENTS + " entries");
|
||||
} else {
|
||||
for (int i = 0; i < array.size(); i++) {
|
||||
final JsonObject incidentObject = objectOrNull(array.get(i));
|
||||
if (incidentObject == null) {
|
||||
errors.add("incidents[" + i + "] must be an object");
|
||||
continue;
|
||||
}
|
||||
final I4cComplaint.Incident incident = parseIncident(i, normalise(incidentObject), errors, codes, today, maxFutureDays);
|
||||
if (incident != null) {
|
||||
incidents.add(incident);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
return new Result(null, errors, codes);
|
||||
}
|
||||
final I4cComplaint complaint = new I4cComplaint(ack, subCategory, firstText(top, instrument, "requestor"),
|
||||
firstText(top, instrument, "payerbank"), firstText(top, instrument, "payerbankcode"),
|
||||
firstText(top, instrument, "modeofpayment"), firstText(top, instrument, "payermobilenumber"), payerAccount,
|
||||
firstText(top, instrument, "state"), firstText(top, instrument, "district"), firstText(top, instrument, "transactiontype"),
|
||||
firstText(top, instrument, "wallet"), incidents);
|
||||
return new Result(complaint, errors, codes);
|
||||
}
|
||||
|
||||
private static I4cComplaint.Incident parseIncident(final int index, final Map<String, JsonElement> f, final List<String> errors,
|
||||
final Set<String> codes, final LocalDate today, final int maxFutureDays) {
|
||||
final String where = "incidents[" + index + "].";
|
||||
final int before = errors.size();
|
||||
final BigDecimal amount = decimal(f, "amount", where, errors, codes);
|
||||
final BigDecimal disputed = decimal(f, "disputedamount", where, errors, codes);
|
||||
final String rrn = text(f.get("rrn"));
|
||||
if (rrn == null) {
|
||||
errors.add(where + "rrn is required");
|
||||
} else if (rrn.length() > 64) {
|
||||
errors.add(where + "rrn is longer than 64 characters");
|
||||
}
|
||||
final String dateText = text(f.get("transactiondate"));
|
||||
LocalDate date = null;
|
||||
if (dateText == null) {
|
||||
errors.add(where + "transaction_date is required");
|
||||
} else {
|
||||
date = parseDate(dateText);
|
||||
if (date == null) {
|
||||
errors.add(where + "transaction_date '" + dateText + "' is not a date (yyyy-MM-dd or dd-MM-yyyy)");
|
||||
codes.add(I4cCodes.INVALID_DATE);
|
||||
} else if (today != null && date.isAfter(today.plusDays(Math.max(0, maxFutureDays)))) {
|
||||
errors.add(where + "transaction_date " + date + " is in the future");
|
||||
codes.add(I4cCodes.INVALID_DATE);
|
||||
}
|
||||
}
|
||||
final String layerText = text(f.get("layer"));
|
||||
int layer = -1;
|
||||
if (layerText == null) {
|
||||
errors.add(where + "layer is required");
|
||||
} else {
|
||||
try {
|
||||
layer = new BigDecimal(layerText).intValueExact();
|
||||
if (layer < 0) {
|
||||
errors.add(where + "layer cannot be negative");
|
||||
}
|
||||
} catch (final NumberFormatException | ArithmeticException e) {
|
||||
errors.add(where + "layer '" + layerText + "' is not a whole number");
|
||||
}
|
||||
}
|
||||
if (errors.size() > before) {
|
||||
return null;
|
||||
}
|
||||
return new I4cComplaint.Incident(index + 1, amount, rrn, date, text(f.get("transactiontime")), disputed, layer,
|
||||
text(f.get("first6digit")), text(f.get("last4digit")), text(f.get("cardlength")));
|
||||
}
|
||||
|
||||
private static BigDecimal decimal(final Map<String, JsonElement> f, final String key, final String where, final List<String> errors,
|
||||
final Set<String> codes) {
|
||||
final String value = text(f.get(key));
|
||||
if (value == null) {
|
||||
errors.add(where + key + " is required");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final BigDecimal parsed = new BigDecimal(value.replace(",", ""));
|
||||
if (parsed.signum() <= 0) {
|
||||
errors.add(where + key + " must be greater than zero");
|
||||
codes.add(I4cCodes.INVALID_AMOUNT);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (final NumberFormatException e) {
|
||||
errors.add(where + key + " '" + value + "' is not an amount");
|
||||
codes.add(I4cCodes.INVALID_AMOUNT);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static LocalDate parseDate(final String value) {
|
||||
final String trimmed = value.trim();
|
||||
// A date-time ("2023-01-02 15:02" or ISO with T) still carries the date we need.
|
||||
final String datePart = trimmed.length() > 10 && (trimmed.charAt(10) == ' ' || trimmed.charAt(10) == 'T') ? trimmed.substring(0, 10)
|
||||
: trimmed;
|
||||
for (final DateTimeFormatter format : DATE_FORMATS) {
|
||||
try {
|
||||
return LocalDate.parse(datePart, format);
|
||||
} catch (final DateTimeParseException ignored) {
|
||||
// try the next one
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String firstText(final Map<String, JsonElement> first, final Map<String, JsonElement> second, final String... keys) {
|
||||
for (final String key : keys) {
|
||||
String value = text(first.get(key));
|
||||
if (value == null) {
|
||||
value = text(second.get(key));
|
||||
}
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, JsonElement> normalise(final JsonObject object) {
|
||||
final Map<String, JsonElement> out = new HashMap<>();
|
||||
for (final Map.Entry<String, JsonElement> e : object.entrySet()) {
|
||||
out.putIfAbsent(normaliseKey(e.getKey()), e.getValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String normaliseKey(final String key) {
|
||||
return key.replaceAll("[\\s_\\-.]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static JsonObject objectOrNull(final JsonElement element) {
|
||||
return element != null && element.isJsonObject() ? element.getAsJsonObject() : null;
|
||||
}
|
||||
|
||||
private static String text(final JsonElement element) {
|
||||
if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
final String value = element.getAsString().trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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.jobs;
|
||||
|
||||
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;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.batch.support.transaction.ResourcelessTransactionManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
/**
|
||||
* The two I4C jobs. JobStarter gives both the tenant and the system user, which the account write services need.
|
||||
* <ul>
|
||||
* <li><b>I4C Dispatch</b> (every minute): processes complaints the immediate worker did not finish, sends responses
|
||||
* waiting in the outbox, and clears expired I4C tokens.</li>
|
||||
* <li><b>I4C Hold Review</b> (every 15 minutes): tops up prospective holds from money that has arrived since.</li>
|
||||
* </ul>
|
||||
* Both do nothing while {@code fineract.i4c.enabled} is false.
|
||||
*
|
||||
* The work that changes accounts (each incident of a complaint, each hold top-up) runs in a transaction of its own, so
|
||||
* one failure does not undo the rest of the run, and each failure is caught and recorded rather than ending the step.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class I4cJobsConfig {
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
/**
|
||||
* The steps run outside any database transaction: complaint processing claims a request row and then locks it again
|
||||
* from an inner transaction, which would wait forever on an outer transaction holding the same row.
|
||||
*/
|
||||
private static final PlatformTransactionManager NO_OUTER_TRANSACTION = new ResourcelessTransactionManager();
|
||||
private final I4cProperties properties;
|
||||
private final I4cProcessingRunner runner;
|
||||
private final I4cOutboxService outbox;
|
||||
private final I4cInboundAuthService authService;
|
||||
private final I4cHoldService holdService;
|
||||
private final I4cIntimationService intimationService;
|
||||
private final I4cRestorationExecutor restorationExecutor;
|
||||
private final I4cRiskService riskService;
|
||||
private final I4cBankHoldService bankHoldService;
|
||||
|
||||
@Bean
|
||||
public Job i4cDispatchJob() {
|
||||
return new JobBuilder(JobName.I4C_DISPATCH.name(), jobRepository).start(i4cDispatchStep()).incrementer(new RunIdIncrementer())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step i4cDispatchStep() {
|
||||
return new StepBuilder(JobName.I4C_DISPATCH.name(), jobRepository).tasklet(dispatchTasklet(), NO_OUTER_TRANSACTION).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Job i4cHoldReviewJob() {
|
||||
return new JobBuilder(JobName.I4C_HOLD_REVIEW.name(), jobRepository).start(i4cHoldReviewStep()).incrementer(new RunIdIncrementer())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected Step i4cHoldReviewStep() {
|
||||
return new StepBuilder(JobName.I4C_HOLD_REVIEW.name(), jobRepository).tasklet(holdReviewTasklet(), NO_OUTER_TRANSACTION).build();
|
||||
}
|
||||
|
||||
private Tasklet dispatchTasklet() {
|
||||
return (contribution, chunkContext) -> {
|
||||
if (properties.isEnabled()) {
|
||||
final int processed = runner.processPending();
|
||||
final int sent = outbox.dispatchDue();
|
||||
final int purged = authService.purgeExpiredTokens();
|
||||
if (processed + sent + purged > 0) {
|
||||
log.info("I4C Dispatch: {} complaint(s) processed, {} response(s) sent, {} expired token(s) purged", processed, sent,
|
||||
purged);
|
||||
}
|
||||
}
|
||||
return RepeatStatus.FINISHED;
|
||||
};
|
||||
}
|
||||
|
||||
private Tasklet holdReviewTasklet() {
|
||||
return (contribution, chunkContext) -> {
|
||||
if (properties.isEnabled()) {
|
||||
final int toppedUp = holdService.topUpProspectiveHolds();
|
||||
// Holds whose first money arrived by top-up, or whose automatic notice failed, are told now.
|
||||
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);
|
||||
}
|
||||
}
|
||||
return RepeatStatus.FINISHED;
|
||||
};
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 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 com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsAccountWritePlatformService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Everything the I4C integration does to, or reads from, a customer account, in one place.
|
||||
*
|
||||
* Holds go through the same write services a teller's "Hold Amount" uses
|
||||
* ({@link SavingsAccountWritePlatformService#holdAmount},
|
||||
* {@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. 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.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cAccountGateway {
|
||||
|
||||
public static final String SAVINGS = "SAVINGS";
|
||||
public static final String CURRENT = "CURRENT";
|
||||
private static final int ACTIVE = 300;
|
||||
private static final int DEPOSIT_TYPE_SAVINGS = 100;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final SavingsAccountRepositoryWrapper savingsAccountRepository;
|
||||
private final SavingsAccountWritePlatformService savingsWriteService;
|
||||
private final CurrentAccountTransactionWritePlatformService currentWriteService;
|
||||
|
||||
/** An account of this bank, found by number. {@code holdable} is false for FD/RD and for accounts not active. */
|
||||
public record AccountRef(String type, Long id, String accountNo, Long clientId, Long officeId, int status, boolean holdable) {
|
||||
|
||||
public boolean isSavings() {
|
||||
return SAVINGS.equals(type);
|
||||
}
|
||||
}
|
||||
|
||||
/** What the specification asks to be reported about the account holder and branch (layer 1 and beyond). */
|
||||
public record HolderDetails(String mobile, String email, String pan, String ifsc) {
|
||||
}
|
||||
|
||||
public Optional<AccountRef> resolve(final String accountNo) {
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
final String number = accountNo.trim();
|
||||
final List<Map<String, Object>> savings = jdbcTemplate.queryForList("""
|
||||
SELECT sa.id, sa.account_no, sa.client_id, c.office_id, sa.status_enum, sa.deposit_type_enum
|
||||
FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
WHERE sa.account_no = ?
|
||||
ORDER BY CASE WHEN sa.status_enum = 300 THEN 0 ELSE 1 END, sa.id DESC
|
||||
""", number);
|
||||
if (!savings.isEmpty()) {
|
||||
final Map<String, Object> r = savings.get(0);
|
||||
final int status = intOf(r.get("status_enum"));
|
||||
final boolean holdable = status == ACTIVE && intOf(r.get("deposit_type_enum")) == DEPOSIT_TYPE_SAVINGS;
|
||||
return Optional.of(new AccountRef(SAVINGS, longOf(r.get("id")), (String) r.get("account_no"), longOf(r.get("client_id")),
|
||||
longOf(r.get("office_id")), status, holdable));
|
||||
}
|
||||
final List<Map<String, Object>> current = jdbcTemplate.queryForList("""
|
||||
SELECT ca.id, ca.account_no, ca.client_id, ca.office_id, ca.status_enum
|
||||
FROM m_current_account ca
|
||||
WHERE ca.account_no = ?
|
||||
ORDER BY CASE WHEN ca.status_enum = 300 THEN 0 ELSE 1 END, ca.id DESC
|
||||
""", number);
|
||||
if (!current.isEmpty()) {
|
||||
final Map<String, Object> r = current.get(0);
|
||||
final int status = intOf(r.get("status_enum"));
|
||||
return Optional.of(new AccountRef(CURRENT, longOf(r.get("id")), (String) r.get("account_no"), longOf(r.get("client_id")),
|
||||
longOf(r.get("office_id")), status, status == ACTIVE));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind of a nodal, pool or escrow account the bank listed (MHA SOP 02.01.2026, para 9.1(iii): the disputed amount
|
||||
* is held, but no prospective hold - 9.1(i) - and normally no suspension or seizure).
|
||||
*/
|
||||
public Optional<String> exclusion(final String type, final Long id) {
|
||||
final List<String> kinds = jdbcTemplate.queryForList(
|
||||
"SELECT kind FROM m_i4c_hold_exclusion WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'", String.class, type, id);
|
||||
return kinds.isEmpty() ? Optional.empty() : Optional.of(kinds.get(0));
|
||||
}
|
||||
|
||||
public Optional<AccountRef> byId(final String type, final Long id) {
|
||||
final String sql = SAVINGS.equals(type) ? "SELECT account_no FROM m_savings_account WHERE id = ?"
|
||||
: "SELECT account_no FROM m_current_account WHERE id = ?";
|
||||
final List<String> numbers = jdbcTemplate.queryForList(sql, String.class, id);
|
||||
return numbers.isEmpty() ? Optional.empty() : resolve(numbers.get(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance the customer could withdraw right now, after every existing hold, lien and minimum balance. Not marked
|
||||
* read-only: it runs inside the transaction that then places the hold, and must not change that transaction.
|
||||
*/
|
||||
@Transactional
|
||||
public BigDecimal withdrawable(final AccountRef account) {
|
||||
if (account.isSavings()) {
|
||||
final SavingsAccount savings = savingsAccountRepository.findOneWithNotFoundDetection(account.id());
|
||||
return nonNegative(savings.getWithdrawableBalance());
|
||||
}
|
||||
final BigDecimal available = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(account_balance_derived, 0) - COALESCE(on_hold_funds_derived, 0) FROM m_current_account WHERE id = ?",
|
||||
BigDecimal.class, account.id());
|
||||
return nonNegative(available);
|
||||
}
|
||||
|
||||
/** Ledger balance, reported to I4C as root_effective_balance. */
|
||||
public BigDecimal ledgerBalance(final AccountRef account) {
|
||||
final String sql = account.isSavings() ? "SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?"
|
||||
: "SELECT COALESCE(account_balance_derived, 0) FROM m_current_account WHERE id = ?";
|
||||
return jdbcTemplate.queryForObject(sql, BigDecimal.class, account.id());
|
||||
}
|
||||
|
||||
/** Places an amount hold and returns the hold transaction id, which is what releases it later. */
|
||||
public Long placeHold(final AccountRef account, final BigDecimal amount, final String reason) {
|
||||
final JsonObject json = new JsonObject();
|
||||
json.addProperty("locale", "en");
|
||||
json.addProperty("dateFormat", "yyyy-MM-dd");
|
||||
json.addProperty("transactionDate", DateUtils.getBusinessLocalDate().toString());
|
||||
json.addProperty("transactionAmount", amount.setScale(2, RoundingMode.DOWN));
|
||||
json.addProperty("reasonForBlock", truncate(reason, 100));
|
||||
final CommandProcessingResult result;
|
||||
if (account.isSavings()) {
|
||||
json.addProperty("lienAllowed", false);
|
||||
result = savingsWriteService.holdAmount(account.id(), JsonCommand.fromJsonElement(account.id(), json, fromJsonHelper));
|
||||
} else {
|
||||
result = currentWriteService.holdAmount(account.id(), JsonCommand.fromJsonElement(account.id(), json, fromJsonHelper));
|
||||
}
|
||||
return result.getResourceId();
|
||||
}
|
||||
|
||||
/**
|
||||
* False once the hold transaction has been released, including by a teller at the counter, so a release from the
|
||||
* register does not fail on a hold that is already gone.
|
||||
*/
|
||||
public boolean isHoldStillOn(final AccountRef account, final Long holdTxnId) {
|
||||
final List<Map<String, Object>> rows = account.isSavings() ? jdbcTemplate.queryForList(
|
||||
"SELECT release_id_of_hold_amount AS released, is_reversed FROM m_savings_account_transaction WHERE id = ?", holdTxnId)
|
||||
: jdbcTemplate.queryForList("SELECT NULL AS released, is_reversed FROM m_current_account_transaction WHERE id = ?",
|
||||
holdTxnId);
|
||||
if (rows.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return rows.get(0).get("released") == null && !Boolean.TRUE.equals(rows.get(0).get("is_reversed"));
|
||||
}
|
||||
|
||||
public void releaseHold(final AccountRef account, final Long holdTxnId) {
|
||||
if (account.isSavings()) {
|
||||
savingsWriteService.releaseAmount(account.id(), holdTxnId);
|
||||
} else {
|
||||
currentWriteService.releaseAmount(account.id(), holdTxnId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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) {
|
||||
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());
|
||||
}
|
||||
|
||||
public HolderDetails holderDetails(final AccountRef account) {
|
||||
String mobile = null;
|
||||
String email = null;
|
||||
String pan = null;
|
||||
if (account.clientId() != null) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT c.mobile_no, c.email_address,
|
||||
COALESCE(NULLIF(kyc.pan_number, ''), (
|
||||
SELECT ci.document_key FROM m_client_identifier ci
|
||||
JOIN m_code_value cv ON cv.id = ci.document_type_id
|
||||
WHERE ci.client_id = c.id AND UPPER(cv.code_value) IN ('PAN CARD', 'PAN')
|
||||
ORDER BY ci.id DESC LIMIT 1)) AS pan
|
||||
FROM m_client c LEFT JOIN m_client_kyc kyc ON kyc.client_id = c.id
|
||||
WHERE c.id = ?
|
||||
""", account.clientId());
|
||||
if (!rows.isEmpty()) {
|
||||
mobile = (String) rows.get(0).get("mobile_no");
|
||||
email = (String) rows.get(0).get("email_address");
|
||||
pan = (String) rows.get(0).get("pan");
|
||||
}
|
||||
}
|
||||
return new HolderDetails(mobile, email, pan == null ? null : pan.toUpperCase(java.util.Locale.ROOT),
|
||||
branchIfsc(account.officeId()));
|
||||
}
|
||||
|
||||
/** IFSC of the account's branch, from the branch profile; null when the profile has none. */
|
||||
public String branchIfsc(final Long officeId) {
|
||||
if (officeId == null) {
|
||||
return null;
|
||||
}
|
||||
final List<String> rows = jdbcTemplate.queryForList("SELECT ifsc_code FROM m_office_profile WHERE office_id = ?", String.class,
|
||||
officeId);
|
||||
return rows.isEmpty() || rows.get(0) == null || rows.get(0).isBlank() ? null : rows.get(0).trim();
|
||||
}
|
||||
|
||||
/** Branch name and contact phone, for the cheque-withdrawal entry (location / managername / managernumber). */
|
||||
public record BranchContact(String name, String phone) {
|
||||
}
|
||||
|
||||
public BranchContact branchContact(final Long officeId) {
|
||||
if (officeId == null) {
|
||||
return new BranchContact(null, null);
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT o.name, p.contact_phone FROM m_office o LEFT JOIN m_office_profile p ON p.office_id = o.id WHERE o.id = ?
|
||||
""", officeId);
|
||||
return rows.isEmpty() ? new BranchContact(null, null)
|
||||
: new BranchContact((String) rows.get(0).get("name"), (String) rows.get(0).get("contact_phone"));
|
||||
}
|
||||
|
||||
|
||||
private static BigDecimal nonNegative(final BigDecimal value) {
|
||||
return value == null || value.signum() < 0 ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
return value == null || value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
|
||||
static Long longOf(final Object value) {
|
||||
return value instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
static int intOf(final Object value) {
|
||||
return value instanceof Number n ? n.intValue() : 0;
|
||||
}
|
||||
}
|
||||
+803
@@ -0,0 +1,803 @@
|
||||
/**
|
||||
* 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.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Read side of the officer screens: requests, holds, outbox, API log, health and the bank master. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cAdminReadService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cProperties properties;
|
||||
private final I4cMoneyMover mover;
|
||||
|
||||
public Map<String, Object> dashboard() {
|
||||
final Timestamp dayAgo = Timestamp.from(Instant.now().minusSeconds(86_400));
|
||||
final Timestamp monthAgo = Timestamp.from(Instant.now().minusSeconds(30L * 86_400));
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("requestsLast24h", count("SELECT COUNT(*) FROM m_i4c_request WHERE received_at >= ?", dayAgo));
|
||||
out.put("requestsLast30d", count("SELECT COUNT(*) FROM m_i4c_request WHERE received_at >= ?", monthAgo));
|
||||
out.put("requestsPending", count("SELECT COUNT(*) FROM m_i4c_request WHERE status IN ('RECEIVED','PROCESSING','RETRY')"));
|
||||
out.put("requestsByStatus", rows("SELECT status, COUNT(*) AS count FROM m_i4c_request GROUP BY status ORDER BY status"));
|
||||
out.put("activeHolds", count("SELECT COUNT(*) FROM m_i4c_hold WHERE status = 'ACTIVE'"));
|
||||
out.put("amountHeld", decimal("SELECT COALESCE(SUM(amount_held),0) FROM m_i4c_hold WHERE status = 'ACTIVE'"));
|
||||
out.put("prospectiveShortfall", decimal("SELECT COALESCE(SUM(shortfall),0) FROM m_i4c_hold WHERE status = 'ACTIVE'"));
|
||||
out.put("holdsReviewDue", count("SELECT COUNT(*) FROM m_i4c_hold WHERE status = 'ACTIVE' AND review_due_on <= ?",
|
||||
Date.valueOf(DateUtils.getBusinessLocalDate().plusDays(15))));
|
||||
out.put("intimationOverdue",
|
||||
count("SELECT COUNT(*) FROM m_i4c_hold WHERE status = 'ACTIVE' AND customer_intimated_at IS NULL AND intimation_due_by < ?",
|
||||
Timestamp.from(Instant.now())));
|
||||
out.put("pendingApprovals", count("SELECT COUNT(*) FROM m_i4c_hold_action_request WHERE status = 'PENDING'"));
|
||||
out.put("outboxPending", count("SELECT COUNT(*) FROM m_i4c_outbox WHERE status IN ('PENDING','SENDING')"));
|
||||
out.put("outboxFailed", count("SELECT COUNT(*) FROM m_i4c_outbox WHERE status = 'FAILED'"));
|
||||
// GRM / MRM work queues.
|
||||
out.put("grievancesOpen", count("SELECT COUNT(*) FROM m_i4c_grievance WHERE stage <> 'CLOSED'"));
|
||||
out.put("grievancesOverdue", count("""
|
||||
SELECT COUNT(*) FROM m_i4c_grievance
|
||||
WHERE stage <> 'CLOSED' AND ((due_at IS NOT NULL AND due_at < CURRENT_TIMESTAMP)
|
||||
OR (compliance_due_at IS NOT NULL AND compliance_due_at < CURRENT_TIMESTAMP))
|
||||
"""));
|
||||
out.put("grievancesToCarryOut", count("SELECT COUNT(*) FROM m_i4c_grievance WHERE status = 'ALLOWED' AND stage <> 'CLOSED'"));
|
||||
out.put("restorationsOpen", count("SELECT COUNT(*) FROM m_i4c_restoration WHERE role = 'BENEFICIARY_BANK' AND status <> 'CLOSED'"));
|
||||
out.put("restorationsInPool", count("SELECT COUNT(*) FROM m_i4c_restoration WHERE status IN ('IN_POOL','TRANSFER_PENDING')"));
|
||||
out.put("amountInPool", decimal("SELECT COALESCE(SUM(restored_amount),0) FROM m_i4c_restoration WHERE status IN ('IN_POOL','TRANSFER_PENDING')"));
|
||||
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
|
||||
FROM (SELECT CASE WHEN txn_type_id <> 0 THEN txn_type
|
||||
WHEN status_code = '01' THEN 'Record not found (01)'
|
||||
WHEN status_code = '02' THEN 'Invalid RRN (02)'
|
||||
ELSE 'Not processed (' || COALESCE(status_code, '-') || ')' END AS label, amount
|
||||
FROM m_i4c_finding) f
|
||||
GROUP BY label ORDER BY count DESC
|
||||
"""));
|
||||
// The RBI letter's "periodic review" of the API integration: calls, failures and response times per direction.
|
||||
out.put("apiPerformance30d", rows("""
|
||||
SELECT direction, COUNT(*) AS calls,
|
||||
SUM(CASE WHEN http_status BETWEEN 200 AND 299 THEN 1 ELSE 0 END) AS succeeded,
|
||||
SUM(CASE WHEN http_status IS NULL OR http_status >= 300 THEN 1 ELSE 0 END) AS failed,
|
||||
ROUND(AVG(duration_ms)) AS avg_ms, MAX(duration_ms) AS max_ms
|
||||
FROM m_i4c_api_log WHERE created_at >= ? GROUP BY direction ORDER BY direction
|
||||
""", monthAgo));
|
||||
out.put("avgAckToResponseSeconds", jdbcTemplate.queryForObject("""
|
||||
SELECT ROUND(AVG(EXTRACT(EPOCH FROM (o.sent_at - r.received_at))))
|
||||
FROM m_i4c_outbox o JOIN m_i4c_request r ON r.id = o.request_id
|
||||
WHERE o.status = 'SENT' AND o.message_type = 'RESPONSE' AND o.sent_at >= ?
|
||||
""", BigDecimal.class, monthAgo));
|
||||
out.put("configuration", configuration());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What is switched on, never a secret: so an officer can see why nothing is flowing. */
|
||||
public Map<String, Object> configuration() {
|
||||
final Map<String, Object> c = new LinkedHashMap<>();
|
||||
c.put("enabled", properties.isEnabled());
|
||||
c.put("tenant", properties.getTenantIdentifier());
|
||||
c.put("bankCode", properties.getBankCode());
|
||||
c.put("inboundCredentialsConfigured", notBlank(properties.getInbound().getUsername())
|
||||
&& (notBlank(properties.getInbound().getPasswordHash()) || notBlank(properties.getInbound().getPassword())));
|
||||
c.put("inboundPasswordHashed", notBlank(properties.getInbound().getPasswordHash()));
|
||||
c.put("allowedIpCount", properties.getInbound().allowedIpList().size());
|
||||
c.put("cryptoMode", properties.getCrypto().getMode().name());
|
||||
c.put("cryptoPassphraseConfigured", properties.getCrypto().hasPassphrase());
|
||||
c.put("outboundConfigured", properties.getOutbound().isConfigured());
|
||||
c.put("outboundBaseUrl", properties.getOutbound().getBaseUrl());
|
||||
c.put("simulatorEnabled", properties.getSimulator().isEnabled());
|
||||
c.put("holdReviewDays", properties.getHold().getReviewDays());
|
||||
c.put("matchDateWindowDays", properties.getMatch().getDateWindowDays());
|
||||
c.put("automaticIntimationChannels", jdbcTemplate.queryForList(
|
||||
"SELECT channel FROM m_i4c_intimation_channel WHERE enabled = true ORDER BY priority, channel", String.class));
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complaints. Filters: {@code status}, {@code search} (ack no., account, job id, complainant mobile), {@code subCategory},
|
||||
* {@code layer} (0 = our customer is the victim, 1 = fraud money landed here), {@code response} (SENT, PENDING, FAILED,
|
||||
* NONE) and the received date.
|
||||
*/
|
||||
public Map<String, Object> requests(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (
|
||||
SELECT r.id, r.acknowledgement_no, r.job_id, r.sub_category, r.payer_account_number, r.payer_mobile_number, r.state,
|
||||
r.max_layer, r.status, r.processing_attempts, r.processing_error, r.received_at, r.processed_at,
|
||||
(SELECT COUNT(*) FROM m_i4c_incident i WHERE i.request_id = r.id) AS incidents,
|
||||
(SELECT COALESCE(SUM(h.amount_held),0) FROM m_i4c_hold h WHERE h.request_id = r.id) AS amount_held,
|
||||
(SELECT o.status FROM m_i4c_outbox o
|
||||
WHERE o.request_id = r.id AND o.message_type = 'RESPONSE' ORDER BY o.id DESC LIMIT 1)
|
||||
AS response_status
|
||||
FROM m_i4c_request r) x WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
like(sql, params, q.text("subCategory"), "x.sub_category");
|
||||
final Integer layer = q.integer("layer");
|
||||
if (layer != null) {
|
||||
sql.append(layer == 0 ? " AND x.max_layer = 0" : " AND x.max_layer >= 1");
|
||||
}
|
||||
final String response = q.upper("response");
|
||||
if ("NONE".equals(response)) {
|
||||
sql.append(" AND x.response_status IS NULL");
|
||||
} else if ("PENDING".equals(response)) {
|
||||
sql.append(" AND x.response_status IN ('PENDING','SENDING')");
|
||||
} else {
|
||||
equal(sql, params, "x.response_status", response);
|
||||
}
|
||||
like(sql, params, q.text("search"), "x.acknowledgement_no", "x.payer_account_number", "x.job_id", "x.payer_mobile_number");
|
||||
dateRange(sql, params, "x.received_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds. {@code view}: ACTIVE, RELEASED, NOT_HOLDABLE, or one of the work queues PROSPECTIVE (still short), REVIEW_DUE
|
||||
* (within 15 days of review), INTIMATION_DUE (customer not yet told). Further filters: {@code search} (account, ack
|
||||
* no., customer name), {@code accountType}, {@code intimation} (DONE / PENDING), {@code frozen}, {@code layer} and the
|
||||
* date the hold was placed.
|
||||
*/
|
||||
public Map<String, Object> holds(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (
|
||||
SELECT h.*, c.display_name AS client_name, o.name AS office_name,
|
||||
(SELECT COUNT(*) FROM m_i4c_hold_action_request a WHERE a.hold_id = h.id AND a.status = 'PENDING') AS pending_actions
|
||||
FROM m_i4c_hold h
|
||||
LEFT JOIN m_client c ON c.id = h.client_id
|
||||
LEFT JOIN m_office o ON o.id = h.office_id) x
|
||||
WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final String v = q.upper("view") == null ? "" : q.upper("view");
|
||||
switch (v) {
|
||||
case "PROSPECTIVE" -> sql.append(" AND x.status = 'ACTIVE' AND x.shortfall > 0");
|
||||
case "REVIEW_DUE" -> {
|
||||
sql.append(" AND x.status = 'ACTIVE' AND x.review_due_on <= ?");
|
||||
params.add(Date.valueOf(DateUtils.getBusinessLocalDate().plusDays(15)));
|
||||
}
|
||||
case "INTIMATION_DUE" -> sql.append(" AND x.status = 'ACTIVE' AND x.amount_held > 0 AND x.customer_intimated_at IS NULL");
|
||||
case "ACTIVE", "RELEASED", "NOT_HOLDABLE" -> {
|
||||
sql.append(" AND x.status = ?");
|
||||
params.add(v);
|
||||
}
|
||||
default -> {
|
||||
// all
|
||||
}
|
||||
}
|
||||
equal(sql, params, "x.account_type", q.upper("accountType"));
|
||||
final String intimation = q.upper("intimation");
|
||||
if ("DONE".equals(intimation)) {
|
||||
sql.append(" AND x.customer_intimated_at IS NOT NULL");
|
||||
} else if ("PENDING".equals(intimation)) {
|
||||
sql.append(" AND x.customer_intimated_at IS NULL");
|
||||
}
|
||||
if (q.flag("frozen")) {
|
||||
sql.append(" AND x.debit_frozen = true");
|
||||
}
|
||||
final Integer layer = q.integer("layer");
|
||||
if (layer != null) {
|
||||
sql.append(layer == 0 ? " AND x.layer = 0" : " AND x.layer >= 1");
|
||||
}
|
||||
like(sql, params, q.text("search"), "x.account_no", "x.acknowledgement_no", "x.client_name");
|
||||
dateRange(sql, params, "x.held_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
public Map<String, Object> request(final Long id) {
|
||||
final List<Map<String, Object>> found = rows("SELECT * FROM m_i4c_request WHERE id = ?", id);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.request.not.found", "I4C request " + id + " not found.");
|
||||
}
|
||||
final Map<String, Object> out = new LinkedHashMap<>(found.get(0));
|
||||
out.remove("payload_hash");
|
||||
out.put("incidents", rows("SELECT * FROM m_i4c_incident WHERE request_id = ? ORDER BY seq_no", id));
|
||||
out.put("findings",
|
||||
rows("SELECT id, incident_id, txn_type_id, txn_type, amount, disputed_amount, status_code, entry_json, created_at"
|
||||
+ " FROM m_i4c_finding WHERE request_id = ? ORDER BY id", id));
|
||||
out.put("holds", rows("SELECT * FROM m_i4c_hold WHERE request_id = ? ORDER BY id", id));
|
||||
out.put("amount_held", jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount_held),0) FROM m_i4c_hold WHERE request_id = ?",
|
||||
BigDecimal.class, id));
|
||||
// The ack callback shares the outbox; only the findings response decides whether I4C has been answered.
|
||||
out.put("response_status", jdbcTemplate.query(
|
||||
"SELECT status FROM m_i4c_outbox WHERE request_id = ? AND message_type = 'RESPONSE' ORDER BY id DESC LIMIT 1",
|
||||
rs -> rs.next() ? rs.getString(1) : null, id));
|
||||
out.put("outbox",
|
||||
rows("SELECT id, message_type, status, attempts, next_attempt_at, http_status, last_error, response_body, created_at,"
|
||||
+ " sent_at, payload"
|
||||
+ " FROM m_i4c_outbox WHERE request_id = ? ORDER BY id", id));
|
||||
out.put("apiLog",
|
||||
rows("SELECT id, direction, endpoint, http_status, response_code, duration_ms, error, created_at FROM m_i4c_api_log"
|
||||
+ " WHERE acknowledgement_no = ? ORDER BY id DESC LIMIT 50", found.get(0).get("acknowledgement_no")));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
public Map<String, Object> hold(final Long id) {
|
||||
final List<Map<String, Object>> found = rows("""
|
||||
SELECT h.*, c.display_name AS client_name, o.name AS office_name
|
||||
FROM m_i4c_hold h LEFT JOIN m_client c ON c.id = h.client_id LEFT JOIN m_office o ON o.id = h.office_id
|
||||
WHERE h.id = ?
|
||||
""", id);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.hold.not.found", "I4C hold " + id + " not found.");
|
||||
}
|
||||
final Map<String, Object> out = new LinkedHashMap<>(found.get(0));
|
||||
out.put("transactions", rows("SELECT * FROM m_i4c_hold_txn WHERE hold_id = ? ORDER BY id", id));
|
||||
out.put("actions", rows("SELECT * FROM m_i4c_hold_action_request WHERE hold_id = ? ORDER BY id DESC", id));
|
||||
out.put("intimationLog", rows("SELECT * FROM m_i4c_intimation_log WHERE hold_id = ? ORDER BY id DESC", id));
|
||||
out.put("automaticChannels", jdbcTemplate.queryForList(
|
||||
"SELECT channel FROM m_i4c_intimation_channel WHERE enabled = true ORDER BY priority, channel", String.class));
|
||||
out.put("grievances", rows("SELECT id, grievance_no, customer_request, stage, status, due_at, decision, created_at FROM m_i4c_grievance"
|
||||
+ " WHERE hold_id = ? ORDER BY id DESC", id));
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release / freeze / unfreeze requests. Filters: {@code status}, {@code action}, {@code reasonType}, {@code search}
|
||||
* (account, ack no., order reference, maker or checker) and the date raised.
|
||||
*/
|
||||
public Map<String, Object> holdActions(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (
|
||||
SELECT a.*, h.account_no, h.account_type, h.acknowledgement_no, h.amount_held, h.disputed_amount, h.status AS hold_status
|
||||
FROM m_i4c_hold_action_request a JOIN m_i4c_hold h ON h.id = a.hold_id) x WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
equal(sql, params, "x.action", q.upper("action"));
|
||||
equal(sql, params, "x.reason_type", q.upper("reasonType"));
|
||||
like(sql, params, q.text("search"), "x.account_no", "x.acknowledgement_no", "x.order_reference", "x.maker_name", "x.checker_name");
|
||||
dateRange(sql, params, "x.maker_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages to I4C. Filters: {@code status}, {@code type} (ACK / RESPONSE), {@code layer}, {@code search} (ack no., job id)
|
||||
* and the date queued.
|
||||
*/
|
||||
public Map<String, Object> outbox(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (
|
||||
SELECT o.id, o.request_id, r.acknowledgement_no, r.job_id, r.max_layer AS layer, o.message_type, o.status, o.attempts,
|
||||
o.next_attempt_at,
|
||||
o.http_status, o.last_error, o.created_at, o.sent_at, o.payload, o.response_body
|
||||
FROM m_i4c_outbox o JOIN m_i4c_request r ON r.id = o.request_id) x WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
equal(sql, params, "x.message_type", q.upper("type"));
|
||||
final Integer outboxLayer = q.integer("layer");
|
||||
if (outboxLayer != null) {
|
||||
sql.append(" AND x.layer = ?");
|
||||
params.add(outboxLayer);
|
||||
}
|
||||
like(sql, params, q.text("search"), "x.acknowledgement_no", "x.job_id");
|
||||
dateRange(sql, params, "x.created_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every call to and from I4C. Filters: {@code direction}, {@code failures} (HTTP 300+ or no answer), {@code httpStatus},
|
||||
* {@code responseCode}, {@code layer}, {@code search} (endpoint, ack no., caller IP) and the date of the call.
|
||||
* <p>
|
||||
* A log row carries the acknowledgement number, which every layer of one complaint shares, so its layer is that of the
|
||||
* request whose job id the call carried (the ack and response to I4C, and the bank's answer to a push). A token call
|
||||
* has none.
|
||||
*/
|
||||
public Map<String, Object> apiLog(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT id, direction, endpoint, http_status, response_code, acknowledgement_no, duration_ms, client_ip, error, created_at
|
||||
FROM m_i4c_api_log x WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.direction", q.upper("direction"));
|
||||
if (q.flag("failures")) {
|
||||
sql.append(" AND (x.http_status IS NULL OR x.http_status >= 300)");
|
||||
}
|
||||
final Integer httpStatus = q.integer("httpStatus");
|
||||
if (httpStatus != null) {
|
||||
sql.append(" AND x.http_status = ?");
|
||||
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);
|
||||
final Map<String, Object> out = page(sql.toString(), params, "x.id DESC", q);
|
||||
addApiLogLayers(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final String CARRIES_JOB_ID = "r.job_id IS NOT NULL AND (x.request_body LIKE '%' || r.job_id || '%'"
|
||||
+ " OR x.response_body LIKE '%' || r.job_id || '%')";
|
||||
|
||||
/** Sets each listed call's layer from the request whose job id it carried, in one query for the page. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private void addApiLogLayers(final Map<String, Object> out) {
|
||||
final List<Map<String, Object>> items = (List<Map<String, Object>>) out.get("pageItems");
|
||||
if (items == null || items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final List<Object> ids = items.stream().map(i -> i.get("id")).collect(java.util.stream.Collectors.toList());
|
||||
final String marks = String.join(",", java.util.Collections.nCopies(ids.size(), "?"));
|
||||
final Map<Object, Object> layers = new java.util.HashMap<>();
|
||||
for (final Map<String, Object> row : rows("SELECT x.id, MAX(r.max_layer) AS layer FROM m_i4c_api_log x JOIN m_i4c_request r ON "
|
||||
+ CARRIES_JOB_ID + " WHERE x.id IN (" + marks + ") GROUP BY x.id", ids.toArray())) {
|
||||
layers.put(row.get("id"), row.get("layer"));
|
||||
}
|
||||
items.forEach(i -> i.put("layer", layers.get(i.get("id"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
public Map<String, Object> apiLogEntry(final Long id) {
|
||||
final List<Map<String, Object>> found = rows("SELECT * FROM m_i4c_api_log WHERE id = ?", id);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.log.not.found", "I4C API log entry " + id + " not found.");
|
||||
}
|
||||
return found.get(0);
|
||||
}
|
||||
|
||||
/** The I4C bank code master. Filters: {@code search} (name, code, IFSC prefix), {@code type}, {@code ifsc} (KNOWN / UNKNOWN). */
|
||||
public Map<String, Object> bankMaster(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder(
|
||||
"SELECT bank_code, bank_name, entity_type, ifsc_prefix FROM m_i4c_bank_master x WHERE 1=1");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final String search = q.text("search");
|
||||
if (search != null) {
|
||||
sql.append(" AND (x.bank_name ILIKE ? OR CAST(x.bank_code AS VARCHAR(10)) = ? OR x.ifsc_prefix = ?)");
|
||||
params.add("%" + search + "%");
|
||||
params.add(search);
|
||||
params.add(search.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
equal(sql, params, "x.entity_type", q.upper("type"));
|
||||
final String ifsc = q.upper("ifsc");
|
||||
if ("KNOWN".equals(ifsc)) {
|
||||
sql.append(" AND x.ifsc_prefix IS NOT NULL");
|
||||
} else if ("UNKNOWN".equals(ifsc)) {
|
||||
sql.append(" AND x.ifsc_prefix IS NULL");
|
||||
}
|
||||
return page(sql.toString(), params, "x.bank_code", q);
|
||||
}
|
||||
|
||||
// ── GRM grievances and MRM restorations ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* GRM grievances. Filters: {@code stage}, {@code status}, {@code overdue} (past the stage deadline or the 48-hour
|
||||
* compliance), {@code search} (GR id, account, ack no., customer) and the date raised.
|
||||
*/
|
||||
public Map<String, Object> grievances(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (
|
||||
SELECT g.id, g.hold_id, g.grievance_no, g.customer_request, g.edd_done, g.edd_recommendation, g.stage, g.status, g.stage_entered_at,
|
||||
g.due_at, g.decision, g.decision_level, g.decision_order_ref, g.compliance_due_at, g.action_request_id, g.created_by, g.created_at,
|
||||
g.updated_at, h.account_no, h.acknowledgement_no, h.amount_held, h.debit_frozen, c.display_name AS client_name,
|
||||
(g.stage <> 'CLOSED' AND g.due_at IS NOT NULL AND g.due_at < CURRENT_TIMESTAMP) AS overdue,
|
||||
(g.stage <> 'CLOSED' AND g.compliance_due_at IS NOT NULL AND g.compliance_due_at < CURRENT_TIMESTAMP) AS compliance_overdue
|
||||
FROM m_i4c_grievance g
|
||||
JOIN m_i4c_hold h ON h.id = g.hold_id
|
||||
LEFT JOIN m_client c ON c.id = h.client_id) x WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final String stage = q.upper("stage");
|
||||
if ("OPEN".equals(stage)) {
|
||||
sql.append(" AND x.stage <> 'CLOSED'");
|
||||
} else {
|
||||
equal(sql, params, "x.stage", stage);
|
||||
}
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
if (q.flag("overdue")) {
|
||||
sql.append(" AND (x.overdue OR x.compliance_overdue)");
|
||||
}
|
||||
like(sql, params, q.text("search"), "x.grievance_no", "x.account_no", "x.acknowledgement_no", "x.client_name");
|
||||
dateRange(sql, params, "x.created_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
public Map<String, Object> grievance(final Long id) {
|
||||
final List<Map<String, Object>> found = rows("""
|
||||
SELECT g.*, h.account_no, h.account_type, h.acknowledgement_no, h.amount_held, h.disputed_amount, h.debit_frozen, h.status AS hold_status,
|
||||
c.display_name AS client_name,
|
||||
(g.stage <> 'CLOSED' AND g.due_at IS NOT NULL AND g.due_at < CURRENT_TIMESTAMP) AS overdue,
|
||||
(g.stage <> 'CLOSED' AND g.compliance_due_at IS NOT NULL AND g.compliance_due_at < CURRENT_TIMESTAMP) AS compliance_overdue
|
||||
FROM m_i4c_grievance g JOIN m_i4c_hold h ON h.id = g.hold_id LEFT JOIN m_client c ON c.id = h.client_id
|
||||
WHERE g.id = ?
|
||||
""", id);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.grievance.not.found", "Grievance " + id + " not found.");
|
||||
}
|
||||
final Map<String, Object> out = new LinkedHashMap<>(found.get(0));
|
||||
out.put("events", rows("SELECT * FROM m_i4c_grievance_event WHERE grievance_id = ? ORDER BY id DESC", id));
|
||||
out.put("documents", documentsOf("GRIEVANCE", id));
|
||||
out.put("requiredDocuments", I4cGrievanceService.REQUIRED_DOCUMENTS);
|
||||
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")));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* MRM restorations and victim-bank cases. Filters: {@code role}, {@code status}, {@code bankAction}, {@code search}
|
||||
* (MR id, ack no., victim name / account, account on hold, UTR) and the date opened.
|
||||
*/
|
||||
public Map<String, Object> restorations(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (
|
||||
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, 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
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.role", q.upper("role"));
|
||||
final String status = q.upper("status");
|
||||
if ("OPEN".equals(status)) {
|
||||
sql.append(" AND x.status <> 'CLOSED'");
|
||||
} else {
|
||||
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);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
public Map<String, Object> restoration(final Long id) {
|
||||
final List<Map<String, Object>> found = rows("""
|
||||
SELECT r.*, h.account_no AS hold_account_no, h.account_type AS hold_account_type, h.amount_held, h.disputed_amount,
|
||||
h.status AS hold_status, 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
|
||||
WHERE r.id = ?
|
||||
""", id);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.restoration.not.found", "Restoration " + id + " not found.");
|
||||
}
|
||||
final Map<String, Object> out = new LinkedHashMap<>(found.get(0));
|
||||
out.put("documents", documentsOf("RESTORATION", id));
|
||||
out.put("firThreshold", properties.getRestoration().getFirThreshold());
|
||||
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")));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mule-account register. Filters: {@code status}, {@code search} (account, reason) and the date flagged. */
|
||||
public Map<String, Object> muleFlags(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (SELECT f.*, c.display_name AS client_name FROM m_i4c_mule_flag f LEFT JOIN m_client c ON c.id = f.client_id) x
|
||||
WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
like(sql, params, q.text("search"), "x.account_no", "x.client_name", "x.reason");
|
||||
dateRange(sql, params, "x.flagged_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/** Fraud reporting register. Filters: {@code status}, {@code overdue}, {@code search} and the classification date. */
|
||||
public Map<String, Object> fraudReports(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (
|
||||
SELECT r.*, c.display_name AS client_name,
|
||||
(r.status IN ('OPEN','SHOW_CAUSE') AND ((r.fmr_filed_on IS NULL AND r.fmr_due_on < CURRENT_DATE)
|
||||
OR (r.str_filed_on IS NULL AND r.str_due_on < CURRENT_DATE))) AS overdue
|
||||
FROM m_i4c_fraud_report r LEFT JOIN m_client c ON c.id = r.client_id) x WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
if (q.flag("overdue")) {
|
||||
sql.append(" AND x.overdue");
|
||||
}
|
||||
like(sql, params, q.text("search"), "x.account_no", "x.client_name", "x.acknowledgement_nos", "x.fmr_ref", "x.str_ref", "x.police_ref");
|
||||
dateRange(sql, params, "x.created_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/** Fraud money that repaid loans here. Filters: {@code status}, {@code search} and the date raised. */
|
||||
public Map<String, Object> linkedAlerts(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (SELECT a.*, h.account_no AS hold_account_no, h.acknowledgement_no FROM m_i4c_linked_alert a
|
||||
JOIN m_i4c_hold h ON h.id = a.hold_id) x WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
like(sql, params, q.text("search"), "x.hold_account_no", "x.target_no", "x.acknowledgement_no");
|
||||
dateRange(sql, params, "x.created_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/** The bank's IFSC -> I4C Bank Code map, and the IFSCs reported with no code that need mapping. */
|
||||
public Map<String, Object> ifscMap() {
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("mappings", rows("""
|
||||
SELECT m.*, b.bank_name FROM m_i4c_ifsc_map m LEFT JOIN m_i4c_bank_master b ON b.bank_code = m.bank_code ORDER BY m.ifsc_key
|
||||
"""));
|
||||
out.put("unknown", rows("""
|
||||
SELECT SUBSTRING(entry_json FROM '"ifsc_code":"([A-Z0-9]*)"') AS ifsc, COUNT(*) AS times, MAX(created_at) AS last_seen
|
||||
FROM m_i4c_finding WHERE txn_type_id = 3 AND entry_json LIKE '%"payee_bank_code":"0"%'
|
||||
GROUP BY 1 ORDER BY 2 DESC LIMIT 100
|
||||
"""));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Nodal / pool / escrow accounts (prospective hold not applied). Filters: {@code status}, {@code search}. */
|
||||
public Map<String, Object> exclusions(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("SELECT * FROM m_i4c_hold_exclusion x WHERE 1=1");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
like(sql, params, q.text("search"), "x.account_no", "x.reason", "x.kind");
|
||||
dateRange(sql, params, "x.added_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bank-initiated debit holds (RBI draft mule SOP). Filters: {@code status} (or OPEN), {@code due} (a decision, police
|
||||
* instruction or grievance past its date), {@code search} and the date placed.
|
||||
*/
|
||||
public Map<String, Object> bankHolds(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("""
|
||||
SELECT * FROM (
|
||||
SELECT b.*, c.display_name AS client_name,
|
||||
((b.status IN ('HELD','EXPLAINED') AND b.decision_due_on <= CURRENT_DATE)
|
||||
OR (b.status = 'REFERRED' AND b.police_due_on <= CURRENT_DATE)
|
||||
OR (b.grievance_raised_on IS NOT NULL AND b.grievance_resolved_on IS NULL AND b.grievance_due_on <= CURRENT_DATE)) AS due
|
||||
FROM m_i4c_bank_hold b LEFT JOIN m_client c ON c.id = b.client_id) x WHERE 1=1
|
||||
""");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
final String status = q.upper("status");
|
||||
if ("OPEN".equals(status)) {
|
||||
sql.append(" AND x.status <> 'LIFTED'");
|
||||
} else {
|
||||
equal(sql, params, "x.status", status);
|
||||
}
|
||||
if (q.flag("due")) {
|
||||
sql.append(" AND x.due");
|
||||
}
|
||||
like(sql, params, q.text("search"), "x.account_no", "x.client_name", "x.flagged_txn_ref", "x.ncrp_ref", "x.reason");
|
||||
dateRange(sql, params, "x.placed_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/** Digital-channel suspensions. Filters: {@code status}, {@code search} and the date placed. */
|
||||
public Map<String, Object> channelBlocks(final I4cListQuery q) {
|
||||
final StringBuilder sql = new StringBuilder("SELECT * FROM m_i4c_channel_block x WHERE 1=1");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
equal(sql, params, "x.status", q.upper("status"));
|
||||
like(sql, params, q.text("search"), "x.account_no", "x.reason");
|
||||
dateRange(sql, params, "x.placed_at", q);
|
||||
return page(sql.toString(), params, "x.id DESC", q);
|
||||
}
|
||||
|
||||
/** The account's transactions over a period, for the bank statement GRM and MRM ask for. */
|
||||
public List<Map<String, Object>> statement(final Long holdId, final java.time.LocalDate from, final java.time.LocalDate to) {
|
||||
final List<Map<String, Object>> hold = rows("SELECT account_type, account_id FROM m_i4c_hold WHERE id = ?", holdId);
|
||||
if (hold.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.hold.not.found", "I4C hold " + holdId + " not found.");
|
||||
}
|
||||
final boolean savings = I4cAccountGateway.SAVINGS.equals(hold.get(0).get("account_type"));
|
||||
final String sql = savings ? """
|
||||
SELECT id, transaction_date, transaction_type_enum, amount, running_balance_derived, ref_no, reason_for_block, is_reversed, created_date
|
||||
FROM m_savings_account_transaction WHERE savings_account_id = ? AND transaction_date BETWEEN ? AND ? ORDER BY transaction_date, id
|
||||
""" : """
|
||||
SELECT id, transaction_date, transaction_type_enum, amount, running_balance_derived, ref_no, reason_for_block, is_reversed, created_date
|
||||
FROM m_current_account_transaction WHERE current_account_id = ? AND transaction_date BETWEEN ? AND ? ORDER BY transaction_date, id
|
||||
""";
|
||||
return rows(sql, hold.get(0).get("account_id"), Date.valueOf(from), Date.valueOf(to));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> documentsOf(final String ownerType, final Long ownerId) {
|
||||
return rows("""
|
||||
SELECT id, doc_type, file_name, content_type, size_bytes, sha256, remarks, uploaded_by, uploaded_at
|
||||
FROM m_i4c_document WHERE owner_type = ? AND owner_id = ? ORDER BY id DESC
|
||||
""", ownerType, ownerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a list with the count of every matching row, so the screens can page through the whole history:
|
||||
* {@code {totalFilteredRecords, pageItems}}.
|
||||
*/
|
||||
private Map<String, Object> page(final String sql, final List<Object> params, final String orderBy, final I4cListQuery q) {
|
||||
final long total = count("SELECT COUNT(*) FROM (" + sql + ") counted", params.toArray());
|
||||
final List<Object> pageParams = new ArrayList<>(params);
|
||||
pageParams.add(q.limit());
|
||||
pageParams.add(q.offset());
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("totalFilteredRecords", total);
|
||||
out.put("pageItems", rows(sql + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?", pageParams.toArray()));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void equal(final StringBuilder sql, final List<Object> params, final String column, final String value) {
|
||||
if (value != null) {
|
||||
sql.append(" AND ").append(column).append(" = ?");
|
||||
params.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Case-insensitive "contains" across the given columns. */
|
||||
private static void like(final StringBuilder sql, final List<Object> params, final String search, final String... columns) {
|
||||
if (search == null) {
|
||||
return;
|
||||
}
|
||||
final String pattern = "%" + search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%";
|
||||
sql.append(" AND (");
|
||||
for (int i = 0; i < columns.length; i++) {
|
||||
sql.append(i == 0 ? "" : " OR ").append("CAST(").append(columns[i]).append(" AS VARCHAR) ILIKE ?");
|
||||
params.add(pattern);
|
||||
}
|
||||
sql.append(')');
|
||||
}
|
||||
|
||||
/** From and To are whole days, both included. */
|
||||
private static void dateRange(final StringBuilder sql, final List<Object> params, final String column, final I4cListQuery q) {
|
||||
if (q.fromStart() != null) {
|
||||
sql.append(" AND ").append(column).append(" >= ?");
|
||||
params.add(q.fromStart());
|
||||
}
|
||||
if (q.toEndExclusive() != null) {
|
||||
sql.append(" AND ").append(column).append(" < ?");
|
||||
params.add(q.toEndExclusive());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> rows(final String sql, final Object... params) {
|
||||
final List<Map<String, Object>> raw = jdbcTemplate.queryForList(sql, params);
|
||||
final List<Map<String, Object>> out = new ArrayList<>(raw.size());
|
||||
for (final Map<String, Object> row : raw) {
|
||||
final Map<String, Object> clean = new LinkedHashMap<>();
|
||||
row.forEach((k, v) -> clean.put(k, plain(v)));
|
||||
out.add(clean);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** JSON-friendly values: ISO dates and instants instead of Gson's locale-dependent java.sql formatting. */
|
||||
private static Object plain(final Object value) {
|
||||
if (value instanceof Timestamp ts) {
|
||||
return ts.toInstant().toString();
|
||||
}
|
||||
if (value instanceof Date d) {
|
||||
return d.toLocalDate().toString();
|
||||
}
|
||||
if (value instanceof java.time.temporal.Temporal t) {
|
||||
return t.toString();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private long count(final String sql, final Object... params) {
|
||||
final Long n = jdbcTemplate.queryForObject(sql, Long.class, params);
|
||||
return n == null ? 0 : n;
|
||||
}
|
||||
|
||||
private BigDecimal decimal(final String sql) {
|
||||
return jdbcTemplate.queryForObject(sql, BigDecimal.class);
|
||||
}
|
||||
|
||||
|
||||
private static boolean notBlank(final String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Records every call between the bank and I4C, in both directions.
|
||||
*
|
||||
* The RBI letter (para 4) says the API integration's performance "shall be periodically reviewed to minimise the
|
||||
* failures"; this table is what that review reads (call counts, failures, response times). Credentials, tokens and PANs
|
||||
* are masked before anything is written: the log is for operations, not a second copy of secrets.
|
||||
*
|
||||
* A failure to write the log never fails the call being logged.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cApiLogService {
|
||||
|
||||
public static final String IN = "IN";
|
||||
public static final String OUT = "OUT";
|
||||
private static final int MAX_BODY = 20_000;
|
||||
|
||||
private static final Pattern SECRET_FIELDS = Pattern
|
||||
.compile("(\"(?:password|username|access_token|client_secret|token)\"\\s*:\\s*\")([^\"]*)(\")", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PAN = Pattern.compile("\\b([A-Za-z]{5})\\d{4}([A-Za-z])\\b");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public void record(final String direction, final String endpoint, final Integer httpStatus, final String responseCode,
|
||||
final String acknowledgementNo, final long durationMs, final String clientIp, final String requestBody,
|
||||
final String responseBody, final String error) {
|
||||
try {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_api_log (direction, endpoint, http_status, response_code, acknowledgement_no, duration_ms, client_ip,
|
||||
request_body, response_body, error, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", direction, truncate(endpoint, 300), httpStatus, truncate(responseCode, 10), truncate(acknowledgementNo, 40),
|
||||
durationMs, truncate(clientIp, 64), mask(requestBody), mask(responseBody), truncate(error, 1000),
|
||||
Timestamp.from(Instant.now()));
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("I4C API log entry could not be written ({} {}): {}", direction, endpoint, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
static String mask(final String body) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
String masked = SECRET_FIELDS.matcher(body).replaceAll("$1***$3");
|
||||
masked = PAN.matcher(masked).replaceAll("$1****$2");
|
||||
return truncate(masked, MAX_BODY);
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
return value == null || value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** The I4C Bank Code Master: this bank's own code, and the code of a payee bank found from its IFSC. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cBankDirectory {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cProperties properties;
|
||||
|
||||
public record Bank(int code, String name) {
|
||||
|
||||
public boolean isKnown() {
|
||||
return code > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public Bank ownBank() {
|
||||
final int code = properties.getBankCode();
|
||||
String name = properties.getBankName();
|
||||
if ((name == null || name.isBlank()) && code > 0) {
|
||||
name = nameOf(code);
|
||||
}
|
||||
if (name == null || name.isBlank()) {
|
||||
final List<String> rows = jdbcTemplate.queryForList("SELECT bank_name FROM m_institution_profile WHERE id = 1", String.class);
|
||||
name = rows.isEmpty() ? "" : rows.get(0);
|
||||
}
|
||||
return new Bank(code, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bank behind an IFSC, by its four-letter prefix. Our own IFSCs (m_bank_ifsc) resolve to this bank. An unknown
|
||||
* prefix gives code 0 with the prefix as the name: the IFSC is still reported in full, so I4C can route it.
|
||||
*/
|
||||
public Bank forIfsc(final String ifsc) {
|
||||
if (ifsc == null || ifsc.trim().length() < 4) {
|
||||
return new Bank(0, "");
|
||||
}
|
||||
final String code = ifsc.trim().toUpperCase(Locale.ROOT);
|
||||
final Integer own = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_bank_ifsc WHERE UPPER(ifsc_code) = ?", Integer.class, code);
|
||||
if (own != null && own > 0) {
|
||||
return ownBank();
|
||||
}
|
||||
// The bank's own map first (a full IFSC, then its prefix), then the master's prefixes.
|
||||
final List<Map<String, Object>> mapped = jdbcTemplate.queryForList("""
|
||||
SELECT m.bank_code, COALESCE(b.bank_name, m.note) AS bank_name FROM m_i4c_ifsc_map m
|
||||
LEFT JOIN m_i4c_bank_master b ON b.bank_code = m.bank_code
|
||||
WHERE m.ifsc_key IN (?, ?) ORDER BY LENGTH(m.ifsc_key) DESC LIMIT 1
|
||||
""", code, code.substring(0, 4));
|
||||
if (!mapped.isEmpty()) {
|
||||
return new Bank(((Number) mapped.get(0).get("bank_code")).intValue(), (String) mapped.get(0).get("bank_name"));
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT bank_code, bank_name FROM m_i4c_bank_master WHERE ifsc_prefix = ? ORDER BY bank_code LIMIT 1",
|
||||
code.substring(0, 4));
|
||||
if (rows.isEmpty()) {
|
||||
return new Bank(0, code.substring(0, 4));
|
||||
}
|
||||
return new Bank(((Number) rows.get(0).get("bank_code")).intValue(), (String) rows.get(0).get("bank_name"));
|
||||
}
|
||||
|
||||
private String nameOf(final int code) {
|
||||
final List<String> rows = jdbcTemplate.queryForList("SELECT bank_name FROM m_i4c_bank_master WHERE bank_code = ?", String.class,
|
||||
code);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Temporary debit holds the bank places itself on suspected money-mule transactions, following RBI's draft amendment
|
||||
* to the KYC Directions (11.09.2026, Annex III; effective 01.04.2027 if finalised, earlier adoption allowed):
|
||||
*
|
||||
* <pre>
|
||||
* flag (txn of Rs 1,000 or more) -> hold at once (the transaction's amount; the whole account only as a last resort)
|
||||
* -> tell the customer: reasons, an officer to contact
|
||||
* customer explains within 20 days -> bank decides within 10 days of the explanation (30 days from the hold without one)
|
||||
* satisfied -> lift
|
||||
* not satisfied -> refer to the police through NCRP -> police instruct within 30 days, or the hold lifts on day 31
|
||||
* never more than 60 days without an LEA / competent authority instruction; grievances resolved within 30 days
|
||||
* </pre>
|
||||
*
|
||||
* The lifts the timeline forces are carried out by the I4C Hold Review job; every other step is an officer's.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cBankHoldService {
|
||||
|
||||
public static final BigDecimal MIN_FLAGGED = new BigDecimal("1000");
|
||||
public static final int EXPLANATION_DAYS = 20;
|
||||
public static final int DECISION_DAYS_AFTER_EXPLANATION = 10;
|
||||
public static final int DECISION_DAYS_WITHOUT_EXPLANATION = 30;
|
||||
public static final int POLICE_DAYS = 30;
|
||||
public static final int MAX_DAYS = 60;
|
||||
public static final int GRIEVANCE_DAYS = 30;
|
||||
public static final Set<String> TRIGGERS = Set.of("PROFILE_MISMATCH", "LINKED_TO_REPORTED_ACCOUNT", "OTHER");
|
||||
public static final Set<String> SCOPES = Set.of("TRANSACTION", "ACCOUNT");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cAccountGateway gateway;
|
||||
private final I4cIntimationService intimation;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
/** Places the hold at once and tells the customer on the automatic channels (or leaves it for the officer). */
|
||||
@Transactional
|
||||
public Long place(final String accountNo, final String scope, final String trigger, final String txnRef, final BigDecimal flaggedAmount,
|
||||
final String reason, final AppUser user) {
|
||||
final AccountRef account = gateway.resolve(accountNo).orElseThrow(() -> error("bankhold.account", "Account " + accountNo + " not found."));
|
||||
if (account.status() != 300) {
|
||||
throw error("bankhold.inactive", "Account " + accountNo + " is not active.");
|
||||
}
|
||||
if (gateway.exclusion(account.type(), account.id()).isPresent()) {
|
||||
// The RBI draft excludes nodal, pool and escrow accounts from the mule SOP.
|
||||
throw error("bankhold.excluded", "Account " + accountNo + " is a nodal / pool / escrow account; the mule hold does not apply to it.");
|
||||
}
|
||||
final String s = upper(scope);
|
||||
final String t = upper(trigger);
|
||||
if (!SCOPES.contains(s) || !TRIGGERS.contains(t)) {
|
||||
throw error("bankhold.kind", "Scope must be TRANSACTION or ACCOUNT and the trigger one of " + TRIGGERS + ".");
|
||||
}
|
||||
if (flaggedAmount == null || flaggedAmount.compareTo(MIN_FLAGGED) < 0) {
|
||||
throw error("bankhold.amount", "Only a flagged transaction of Rs 1,000 or more is held (RBI draft).");
|
||||
}
|
||||
if (blank(reason) || ("ACCOUNT".equals(s) && reason.trim().length() < 20)) {
|
||||
throw error("bankhold.reason", "Record the reason; an account-level hold is a last resort and needs the circumstances spelt out.");
|
||||
}
|
||||
final Integer open = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_bank_hold WHERE account_type = ? AND account_id = ? AND status IN ('HELD','EXPLAINED','REFERRED','CONTINUED')",
|
||||
Integer.class, account.type(), account.id());
|
||||
if (open != null && open > 0) {
|
||||
throw error("bankhold.open", "This account already has a bank-initiated hold in force.");
|
||||
}
|
||||
Long holdTxnId = null;
|
||||
BigDecimal held = null;
|
||||
final String note = "Suspected mule txn " + nz(txnRef) + " (bank hold)";
|
||||
if ("TRANSACTION".equals(s)) {
|
||||
held = gateway.withdrawable(account).min(flaggedAmount);
|
||||
if (held.signum() <= 0) {
|
||||
throw error("bankhold.nothing", "Nothing is available to hold on this account.");
|
||||
}
|
||||
holdTxnId = gateway.placeHold(account, held, note);
|
||||
} else {
|
||||
gateway.freezeDebits(account, note);
|
||||
}
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final Timestamp now = now();
|
||||
final Long id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_bank_hold (account_type, account_id, account_no, client_id, office_id, scope, trigger_type, flagged_txn_ref,
|
||||
flagged_amount, hold_amount, hold_txn_id, reason, status, placed_by, placed_at, explanation_due_on,
|
||||
decision_due_on, max_until)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'HELD',?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, account.type(), account.id(), account.accountNo(), account.clientId(), account.officeId(), s, t,
|
||||
trim(txnRef, 100), flaggedAmount, held, holdTxnId, trim(reason, 1000), name(user), now,
|
||||
Date.valueOf(today.plusDays(EXPLANATION_DAYS)), Date.valueOf(today.plusDays(DECISION_DAYS_WITHOUT_EXPLANATION)),
|
||||
Date.valueOf(today.plusDays(MAX_DAYS)));
|
||||
final I4cIntimationService.Result sent = intimation.notifyCustomer(account.clientId(), "Debit hold on your account " + mask(accountNo),
|
||||
"Dear customer,\n\nA temporary debit hold" + (held == null ? " on your account " : " of Rs " + held.toPlainString() + " ")
|
||||
+ mask(accountNo) + " was placed on " + today + " because a transaction (" + nz(txnRef)
|
||||
+ ") did not match your account profile and needs to be explained.\n\nPlease contact your branch within "
|
||||
+ EXPLANATION_DAYS + " days with the source of the funds. If no explanation is received, the bank will decide on the hold"
|
||||
+ " within 30 days. Credits to your account continue normally.\n\nThis is a system-generated message.");
|
||||
if (sent.outcome() == I4cIntimationService.Outcome.SENT) {
|
||||
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET customer_notified_at = ?, notification_mode = ?, notification_ref = ? WHERE id = ?", now,
|
||||
sent.channel(), trim(sent.message(), 200), id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The officer told the customer by hand (when no automatic channel reached them). */
|
||||
@Transactional
|
||||
public void recordNotice(final Long id, final String mode, final String reference, final AppUser user) {
|
||||
load(id, "HELD", "EXPLAINED", "REFERRED", "CONTINUED");
|
||||
if (blank(mode)) {
|
||||
throw error("bankhold.notice", "Say how the customer was told.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET customer_notified_at = ?, notification_mode = ?, notification_ref = ? WHERE id = ?", now(),
|
||||
trim(upper(mode), 20), trim(reference, 200), id);
|
||||
}
|
||||
|
||||
/** The customer's explanation: the decision is due within 10 days of it. */
|
||||
@Transactional
|
||||
public void recordExplanation(final Long id, final LocalDate receivedOn, final String explanation, final AppUser user) {
|
||||
load(id, "HELD");
|
||||
if (receivedOn == null || blank(explanation)) {
|
||||
throw error("bankhold.explanation", "Enter the date and the customer's explanation.");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_bank_hold SET status = 'EXPLAINED', explanation_received_on = ?, explanation = ?, decision_due_on = ? WHERE id = ?
|
||||
""", Date.valueOf(receivedOn), trim(explanation, 2000), Date.valueOf(receivedOn.plusDays(DECISION_DAYS_AFTER_EXPLANATION)), id);
|
||||
}
|
||||
|
||||
/** LIFT (satisfied) or REFER (to the police through NCRP; police then have 30 days). */
|
||||
@Transactional
|
||||
public void decide(final Long id, final String decision, final String remarks, final String ncrpRef, final AppUser user) {
|
||||
final Map<String, Object> h = load(id, "HELD", "EXPLAINED");
|
||||
final String d = upper(decision);
|
||||
if (blank(remarks)) {
|
||||
throw error("bankhold.remarks", "Record the basis of the decision.");
|
||||
}
|
||||
if ("LIFT".equals(d)) {
|
||||
lift(h, "Bank satisfied: " + remarks.trim(), name(user));
|
||||
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET decision = 'LIFT', decision_remarks = ?, decided_by = ?, decided_at = ? WHERE id = ?",
|
||||
trim(remarks, 1000), name(user), now(), id);
|
||||
return;
|
||||
}
|
||||
if (!"REFER".equals(d) || blank(ncrpRef)) {
|
||||
throw error("bankhold.decision", "Decision is LIFT, or REFER with the NCRP reference of the referral to the police.");
|
||||
}
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_bank_hold SET status = 'REFERRED', decision = 'REFER', decision_remarks = ?, decided_by = ?, decided_at = ?,
|
||||
referred_on = ?, ncrp_ref = ?, police_due_on = ?
|
||||
WHERE id = ?
|
||||
""", trim(remarks, 1000), name(user), now(), Date.valueOf(today), trim(ncrpRef, 100), Date.valueOf(today.plusDays(POLICE_DAYS)), id);
|
||||
}
|
||||
|
||||
/** The police's instruction: CONTINUE (the hold then follows the LEA, not the 60-day cap) or LIFT. */
|
||||
@Transactional
|
||||
public void recordPolice(final Long id, final String instruction, final String reference, final AppUser user) {
|
||||
final Map<String, Object> h = load(id, "REFERRED");
|
||||
final String i = upper(instruction);
|
||||
if (blank(reference) || (!"CONTINUE".equals(i) && !"LIFT".equals(i))) {
|
||||
throw error("bankhold.police", "Instruction is CONTINUE or LIFT, with the police reference.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET police_instruction = ?, police_ref = ? WHERE id = ?", i, trim(reference, 200), id);
|
||||
if ("LIFT".equals(i)) {
|
||||
lift(h, "Police instructed lifting (" + reference.trim() + ")", name(user));
|
||||
} else {
|
||||
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET status = 'CONTINUED' WHERE id = ?", id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The customer's grievance on the hold; the nodal officer resolves it within 30 days. */
|
||||
@Transactional
|
||||
public void raiseGrievance(final Long id, final String text, final AppUser user) {
|
||||
load(id, "HELD", "EXPLAINED", "REFERRED", "CONTINUED", "LIFTED");
|
||||
if (blank(text)) {
|
||||
throw error("bankhold.grievance", "Record the customer's grievance.");
|
||||
}
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET grievance_raised_on = ?, grievance_due_on = ?, grievance_text = ? WHERE id = ?",
|
||||
Date.valueOf(today), Date.valueOf(today.plusDays(GRIEVANCE_DAYS)), trim(text, 2000), id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void resolveGrievance(final Long id, final String resolution, final AppUser user) {
|
||||
final Map<String, Object> h = load(id, "HELD", "EXPLAINED", "REFERRED", "CONTINUED", "LIFTED");
|
||||
if (h.get("grievance_raised_on") == null || blank(resolution)) {
|
||||
throw error("bankhold.grievance.resolve", "Record the resolution of the raised grievance.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET grievance_resolved_on = ?, grievance_resolution = ? WHERE id = ?",
|
||||
Date.valueOf(DateUtils.getBusinessLocalDate()), trim(resolution, 2000), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the holds the draft's timeline ends: the police gave no instruction within 30 days of the referral, or 60
|
||||
* days passed without an LEA instruction. Each in its own transaction. Run by the I4C Hold Review job.
|
||||
*/
|
||||
public int liftExpired() {
|
||||
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||
SELECT * FROM m_i4c_bank_hold
|
||||
WHERE (status = 'REFERRED' AND police_due_on < CURRENT_DATE)
|
||||
OR (status IN ('HELD','EXPLAINED','REFERRED') AND max_until < CURRENT_DATE)
|
||||
""");
|
||||
int lifted = 0;
|
||||
final TransactionTemplate tx = new TransactionTemplate(transactionManager);
|
||||
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
for (final Map<String, Object> h : due) {
|
||||
final String why = "REFERRED".equals(h.get("status")) && h.get("police_due_on") != null
|
||||
? "No police instruction within 30 days of the referral" : "60-day limit reached without an LEA instruction";
|
||||
try {
|
||||
tx.executeWithoutResult(status -> lift(h, why, "system"));
|
||||
lifted++;
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("Bank hold {} could not be lifted automatically: {}", h.get("id"), e.getMessage());
|
||||
}
|
||||
}
|
||||
return lifted;
|
||||
}
|
||||
|
||||
private void lift(final Map<String, Object> h, final String reason, final String actor) {
|
||||
final AccountRef account = gateway.byId((String) h.get("account_type"), ((Number) h.get("account_id")).longValue())
|
||||
.orElseThrow(() -> new IllegalStateException("Account " + h.get("account_no") + " no longer exists"));
|
||||
if ("ACCOUNT".equals(h.get("scope"))) {
|
||||
// An I4C freeze on the same account (an LEA / court direction) stays.
|
||||
final Integer i4cFrozen = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_hold WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE' AND debit_frozen = true",
|
||||
Integer.class, h.get("account_type"), h.get("account_id"));
|
||||
if (i4cFrozen == null || i4cFrozen == 0) {
|
||||
gateway.unfreezeDebits(account);
|
||||
}
|
||||
} else if (h.get("hold_txn_id") != null) {
|
||||
final Long txn = ((Number) h.get("hold_txn_id")).longValue();
|
||||
if (gateway.isHoldStillOn(account, txn)) {
|
||||
gateway.releaseHold(account, txn);
|
||||
}
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_bank_hold SET status = 'LIFTED', lifted_at = ?, lifted_by = ?, lift_reason = ? WHERE id = ?", now(),
|
||||
trim(actor, 100), trim(reason, 500), h.get("id"));
|
||||
}
|
||||
|
||||
private Map<String, Object> load(final Long id, final String... allowed) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_bank_hold WHERE id = ? FOR UPDATE", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("bankhold.not.found", "Bank hold " + id + " not found.");
|
||||
}
|
||||
final Map<String, Object> h = rows.get(0);
|
||||
if (!List.of(allowed).contains(h.get("status"))) {
|
||||
throw error("bankhold.status", "Not possible while the hold is " + h.get("status") + ".");
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
static String mask(final String accountNo) {
|
||||
if (accountNo == null || accountNo.length() <= 4) {
|
||||
return accountNo;
|
||||
}
|
||||
return "X".repeat(accountNo.length() - 4) + accountNo.substring(accountNo.length() - 4);
|
||||
}
|
||||
|
||||
private static Timestamp now() {
|
||||
return Timestamp.from(Instant.now());
|
||||
}
|
||||
|
||||
private static String name(final AppUser user) {
|
||||
return user == null ? "system" : trim(user.getDisplayName(), 100);
|
||||
}
|
||||
|
||||
private static String nz(final String value) {
|
||||
return value == null ? "-" : value.trim();
|
||||
}
|
||||
|
||||
private static boolean blank(final String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String upper(final String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String trim(final String value, final int max) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String t = value.trim();
|
||||
return t.length() <= max ? t : t.substring(0, max);
|
||||
}
|
||||
|
||||
private static PlatformDataIntegrityException error(final String code, final String message, final Object... cause) {
|
||||
return new PlatformDataIntegrityException("error.msg.i4c." + code, message, cause);
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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 com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.crypto.I4cCryptoJsAes;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.simulator.I4cSimulator;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Calls I4C (specification section 3, "Authenticate from BANKS to I4C", and section 6, the response message).
|
||||
*
|
||||
* The token is fetched with the username/password I4C issued to the bank, encrypted in the CryptoJS format when the
|
||||
* crypto mode asks for it, and reused until it nears expiry or I4C answers 401, when it is fetched again once. Timeouts
|
||||
* are bounded so a slow I4C never holds up the dispatcher; failures surface to the outbox, which retries.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class I4cClient {
|
||||
|
||||
private static final MediaType JSON = MediaType.get("application/json");
|
||||
/** I4C does not publish its token lifetime; a token is reused for at most this long. */
|
||||
private static final Duration TOKEN_REUSE = Duration.ofMinutes(10);
|
||||
|
||||
private final I4cProperties properties;
|
||||
private final I4cApiLogService apiLog;
|
||||
private final I4cSimulator simulator;
|
||||
private volatile OkHttpClient httpClient;
|
||||
private volatile String cachedToken;
|
||||
private volatile Instant cachedTokenUntil = Instant.EPOCH;
|
||||
|
||||
public I4cClient(final I4cProperties properties, final I4cApiLogService apiLog, final I4cSimulator simulator) {
|
||||
this.properties = properties;
|
||||
this.apiLog = apiLog;
|
||||
this.simulator = simulator;
|
||||
}
|
||||
|
||||
public record Result(int httpStatus, String body, String responseCode) {
|
||||
|
||||
public boolean isSuccess() {
|
||||
return httpStatus >= 200 && httpStatus < 300 && (responseCode == null || "00".equals(responseCode));
|
||||
}
|
||||
}
|
||||
|
||||
/** Posts one response message. Throws only on transport failure; an HTTP error comes back as a Result. */
|
||||
public Result sendResponse(final String responseJson, final String acknowledgementNo) throws IOException {
|
||||
return send(I4cOutboxService.TYPE_RESPONSE, responseJson, acknowledgementNo);
|
||||
}
|
||||
|
||||
/** Posts a section 6 response (RESPONSE) or a section 5 ack (ACK) to its configured path. */
|
||||
public Result send(final String messageType, final String json, final String acknowledgementNo) throws IOException {
|
||||
requireConfigured();
|
||||
final boolean ack = I4cOutboxService.TYPE_ACK.equals(messageType);
|
||||
final String path = ack ? properties.getOutbound().getAckPath() : properties.getOutbound().getResponsePath();
|
||||
if (path == null || path.isBlank()) {
|
||||
throw new IOException("No I4C path configured for " + messageType + " messages");
|
||||
}
|
||||
if (I4cSimulator.INTERNAL_BASE_URL.equalsIgnoreCase(properties.getOutbound().getBaseUrl().trim())) {
|
||||
return sendToSimulator(json, acknowledgementNo, path);
|
||||
}
|
||||
Result result = post(path, json, acknowledgementNo, token(false));
|
||||
if (result.httpStatus() == 401) {
|
||||
result = post(path, json, acknowledgementNo, token(true));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local runs: the response goes to the in-process simulator, logged like a real call, so the whole flow can be
|
||||
* tested where the server cannot reach itself over HTTPS (a self-signed dev certificate).
|
||||
*/
|
||||
private Result sendToSimulator(final String responseJson, final String acknowledgementNo, final String path) throws IOException {
|
||||
if (!properties.getSimulator().isEnabled()) {
|
||||
throw new IOException("outbound base-url is internal-simulator but fineract.i4c.simulator.enabled is false");
|
||||
}
|
||||
final long start = System.nanoTime();
|
||||
final String body = simulator.receiveResponse(responseJson);
|
||||
apiLog.record(I4cApiLogService.OUT, I4cSimulator.INTERNAL_BASE_URL + path, 200, "00",
|
||||
acknowledgementNo, elapsedMs(start), null, responseJson, body, null);
|
||||
return new Result(200, body, "00");
|
||||
}
|
||||
|
||||
private Result post(final String path, final String responseJson, final String acknowledgementNo, final String token)
|
||||
throws IOException {
|
||||
final String body = wrapBody(responseJson);
|
||||
final String url = url(path);
|
||||
final Request request = new Request.Builder().url(url).post(RequestBody.create(body, JSON))
|
||||
.header("Authorization", "Bearer " + token).header("Accept", "application/json").build();
|
||||
final long start = System.nanoTime();
|
||||
try (Response response = client().newCall(request).execute()) {
|
||||
final String responseBody = unwrapBody(bodyOf(response.body()));
|
||||
final String code = responseCodeOf(responseBody);
|
||||
apiLog.record(I4cApiLogService.OUT, url, response.code(), code, acknowledgementNo, elapsedMs(start), null, responseJson,
|
||||
responseBody, null);
|
||||
return new Result(response.code(), responseBody, code);
|
||||
} catch (final IOException e) {
|
||||
apiLog.record(I4cApiLogService.OUT, url, null, null, acknowledgementNo, elapsedMs(start), null, responseJson, null,
|
||||
e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized String token(final boolean forceRefresh) throws IOException {
|
||||
if (!forceRefresh && cachedToken != null && Instant.now().isBefore(cachedTokenUntil)) {
|
||||
return cachedToken;
|
||||
}
|
||||
final I4cProperties.Outbound outbound = properties.getOutbound();
|
||||
final JsonObject credentials = new JsonObject();
|
||||
credentials.addProperty("username", encryptField(outbound.getUsername()));
|
||||
credentials.addProperty("password", encryptField(outbound.getPassword()));
|
||||
final String url = url(outbound.getAuthPath());
|
||||
final Request request = new Request.Builder().url(url).post(RequestBody.create(credentials.toString(), JSON))
|
||||
.header("Accept", "application/json").build();
|
||||
final long start = System.nanoTime();
|
||||
try (Response response = client().newCall(request).execute()) {
|
||||
final String responseBody = bodyOf(response.body());
|
||||
apiLog.record(I4cApiLogService.OUT, url, response.code(), null, null, elapsedMs(start), null, credentials.toString(),
|
||||
responseBody, response.isSuccessful() ? null : "token request refused");
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IOException("I4C token request returned HTTP " + response.code());
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(responseBody);
|
||||
if (!parsed.isJsonObject() || !parsed.getAsJsonObject().has("access_token")) {
|
||||
throw new IOException("I4C token response has no access_token");
|
||||
}
|
||||
cachedToken = parsed.getAsJsonObject().get("access_token").getAsString();
|
||||
cachedTokenUntil = Instant.now().plus(TOKEN_REUSE);
|
||||
return cachedToken;
|
||||
} catch (final IOException e) {
|
||||
cachedToken = null;
|
||||
if (e.getMessage() == null || !e.getMessage().startsWith("I4C token")) {
|
||||
apiLog.record(I4cApiLogService.OUT, url, null, null, null, elapsedMs(start), null, credentials.toString(), null,
|
||||
e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private String encryptField(final String value) {
|
||||
final I4cProperties.Crypto crypto = properties.getCrypto();
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (crypto.getMode() != I4cProperties.CryptoMode.NONE && crypto.hasPassphrase()) {
|
||||
return I4cCryptoJsAes.encrypt(value, crypto.getPassphrase());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String wrapBody(final String json) {
|
||||
final I4cProperties.Crypto crypto = properties.getCrypto();
|
||||
if (crypto.getMode() == I4cProperties.CryptoMode.ENVELOPE && crypto.hasPassphrase()) {
|
||||
final JsonObject envelope = new JsonObject();
|
||||
envelope.addProperty("data", I4cCryptoJsAes.encrypt(json, crypto.getPassphrase()));
|
||||
return envelope.toString();
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
private String unwrapBody(final String body) {
|
||||
final I4cProperties.Crypto crypto = properties.getCrypto();
|
||||
if (crypto.getMode() != I4cProperties.CryptoMode.ENVELOPE || !crypto.hasPassphrase() || body == null || body.isBlank()) {
|
||||
return body;
|
||||
}
|
||||
try {
|
||||
final JsonElement parsed = JsonParser.parseString(body);
|
||||
if (parsed.isJsonObject() && parsed.getAsJsonObject().has("data")
|
||||
&& I4cCryptoJsAes.looksEncrypted(parsed.getAsJsonObject().get("data").getAsString())) {
|
||||
return I4cCryptoJsAes.decrypt(parsed.getAsJsonObject().get("data").getAsString(), crypto.getPassphrase());
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
log.debug("I4C response body was not an encrypted envelope: {}", e.getMessage());
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** I4C's own response code, from {@code meta.response_code} or {@code response_code}, when the body has one. */
|
||||
static String responseCodeOf(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final JsonElement parsed = JsonParser.parseString(body);
|
||||
if (!parsed.isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
final JsonObject o = parsed.getAsJsonObject();
|
||||
if (o.has("meta") && o.get("meta").isJsonObject() && o.getAsJsonObject("meta").has("response_code")) {
|
||||
return o.getAsJsonObject("meta").get("response_code").getAsString();
|
||||
}
|
||||
return o.has("response_code") ? o.get("response_code").getAsString() : null;
|
||||
} catch (final RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private OkHttpClient client() {
|
||||
if (httpClient == null) {
|
||||
synchronized (this) {
|
||||
if (httpClient == null) {
|
||||
final int timeout = Math.max(1000, properties.getOutbound().getTimeoutMs());
|
||||
httpClient = new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(timeout, TimeUnit.MILLISECONDS).writeTimeout(timeout, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(timeout * 2L, TimeUnit.MILLISECONDS).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
private void requireConfigured() throws IOException {
|
||||
if (!properties.getOutbound().isConfigured()) {
|
||||
throw new IOException("fineract.i4c.outbound.base-url is not configured");
|
||||
}
|
||||
}
|
||||
|
||||
private String url(final String path) {
|
||||
String base = properties.getOutbound().getBaseUrl().trim();
|
||||
while (base.endsWith("/")) {
|
||||
base = base.substring(0, base.length() - 1);
|
||||
}
|
||||
final String p = path == null ? "" : path.trim();
|
||||
return base + (p.startsWith("/") || p.isEmpty() ? p : "/" + p);
|
||||
}
|
||||
|
||||
private static String bodyOf(final ResponseBody body) throws IOException {
|
||||
return body == null ? "" : body.string();
|
||||
}
|
||||
|
||||
private static long elapsedMs(final long startNanos) {
|
||||
return (System.nanoTime() - startNanos) / 1_000_000L;
|
||||
}
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* 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 com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.BranchContact;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.HolderDetails;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cBankDirectory.Bank;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cHoldService.HoldOutcome;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cResponseBuilder.Root;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cTransactionTracer.Direction;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cTransactionTracer.MatchedTxn;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cTransactionTracer.Movement;
|
||||
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.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Acts on one complaint push from I4C and writes the response.
|
||||
*
|
||||
* Per incident, in a transaction of its own (a failure on one never undoes the hold placed for another):
|
||||
* <ul>
|
||||
* <li><b>Layer 0</b> - the account is the victim's. The reported debit is found and the bank reports where the money
|
||||
* went: on to another bank (Money Transfer To, with payee IFSC, account and UTR), to another account here, or out at
|
||||
* the branch.</li>
|
||||
* <li><b>Layer 1 and beyond</b> - fraud money came into this account. Up to the disputed amount is held at once
|
||||
* (Transaction Put on Hold). Whatever could not be held is followed: the debits after the credit are reported as Money
|
||||
* Transfer To / Withdrawal, and what is still short becomes a prospective hold on future credits.</li>
|
||||
* <li>An account not found here is reported with status_code 01; a transaction not found on an account here with
|
||||
* status_code 02.</li>
|
||||
* </ul>
|
||||
* Every step is idempotent. An incident that already has an outcome is skipped and its hold is never placed twice, so a
|
||||
* complaint can be processed again after a crash or failure without harm. After {@link #MAX_ATTEMPTS} failed attempts
|
||||
* an incident is reported as an unmapped error (70010) rather than holding up the reply for the rest.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cComplaintProcessor {
|
||||
|
||||
public static final String RECEIVED = "RECEIVED";
|
||||
public static final String PROCESSING = "PROCESSING";
|
||||
public static final String RETRY = "RETRY";
|
||||
public static final String PROCESSED = "PROCESSED";
|
||||
static final int MAX_ATTEMPTS = 5;
|
||||
private static final BigDecimal FIVE_HUNDRED = new BigDecimal("500");
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cAccountGateway gateway;
|
||||
private final I4cTransactionTracer tracer;
|
||||
private final I4cHoldService holdService;
|
||||
private final I4cBankDirectory banks;
|
||||
private final I4cOutboxService outbox;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* Processes the complaint if no one else is. Returns the outbox id of the response written, or null when the
|
||||
* complaint was not claimed or is left for a retry.
|
||||
*/
|
||||
public Long process(final Long requestId) {
|
||||
final int claimed = jdbcTemplate.update(
|
||||
"UPDATE m_i4c_request SET status = ?, processing_attempts = processing_attempts + 1 WHERE id = ? AND status IN (?, ?)",
|
||||
PROCESSING, requestId, RECEIVED, RETRY);
|
||||
if (claimed == 0) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> request = jdbcTemplate.queryForMap("SELECT * FROM m_i4c_request WHERE id = ?", requestId);
|
||||
final int attempts = ((Number) request.get("processing_attempts")).intValue();
|
||||
final List<Map<String, Object>> incidents = jdbcTemplate
|
||||
.queryForList("SELECT * FROM m_i4c_incident WHERE request_id = ? ORDER BY seq_no", requestId);
|
||||
final Set<Long> alreadyMatched = new HashSet<>();
|
||||
incidents.stream().map(i -> i.get("matched_txn_id")).filter(Number.class::isInstance)
|
||||
.forEach(id -> alreadyMatched.add(((Number) id).longValue()));
|
||||
|
||||
final TransactionTemplate tx = newTransaction();
|
||||
String lastError = null;
|
||||
boolean allDone = true;
|
||||
for (final Map<String, Object> incident : incidents) {
|
||||
if (incident.get("outcome") != null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
tx.executeWithoutResult(status -> processIncident(request, incident, alreadyMatched));
|
||||
} catch (final RuntimeException e) {
|
||||
allDone = false;
|
||||
lastError = "Incident " + incident.get("seq_no") + ": "
|
||||
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||
log.error("I4C ack {} incident {} failed (attempt {})", request.get("acknowledgement_no"), incident.get("seq_no"), attempts,
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allDone && attempts < MAX_ATTEMPTS) {
|
||||
jdbcTemplate.update("UPDATE m_i4c_request SET status = ?, processing_error = ? WHERE id = ?", RETRY, truncate(lastError, 2000),
|
||||
requestId);
|
||||
return null;
|
||||
}
|
||||
if (!allDone) {
|
||||
final String error = lastError;
|
||||
tx.executeWithoutResult(status -> reportUnprocessed(request, requestId, error));
|
||||
}
|
||||
final String finalError = lastError;
|
||||
return tx.execute(status -> respond(request, requestId, finalError));
|
||||
}
|
||||
|
||||
private void processIncident(final Map<String, Object> request, final Map<String, Object> incident, final Set<Long> alreadyMatched) {
|
||||
final Long requestId = ((Number) request.get("id")).longValue();
|
||||
final Long incidentId = ((Number) incident.get("id")).longValue();
|
||||
// Locked for the length of this transaction: a second worker (a job run overlapping a retry) waits here and
|
||||
// then finds the outcome already written, instead of holding or reporting the incident twice.
|
||||
final List<String> current = jdbcTemplate.queryForList("SELECT outcome FROM m_i4c_incident WHERE id = ? FOR UPDATE", String.class,
|
||||
incidentId);
|
||||
if (current.isEmpty() || current.get(0) != null) {
|
||||
return;
|
||||
}
|
||||
final String ack = (String) request.get("acknowledgement_no");
|
||||
final String payerAccount = (String) request.get("payer_account_number");
|
||||
final int layer = ((Number) incident.get("layer")).intValue();
|
||||
final BigDecimal amount = (BigDecimal) incident.get("amount");
|
||||
final BigDecimal disputed = (BigDecimal) incident.get("disputed_amount");
|
||||
final String rrn = (String) incident.get("rrn");
|
||||
final LocalDate date = I4cTransactionTracer.toLocalDate(incident.get("transaction_date"));
|
||||
final String reportedDateTime = reportedDateTime(date, (String) incident.get("transaction_time"));
|
||||
final Bank ownBank = banks.ownBank();
|
||||
|
||||
final Optional<AccountRef> found = gateway.resolve(payerAccount);
|
||||
if (found.isEmpty()) {
|
||||
final JsonObject entry = I4cResponseBuilder.failure(payerAccount, rrn, ownBank.code(), amount, disputed, reportedDateTime,
|
||||
I4cCodes.STATUS_RECORD_NOT_FOUND,
|
||||
"Record not found: account does not exist in CBS (" + I4cCodes.ACCOUNT_NOT_FOUND + ")");
|
||||
saveFindings(requestId, incidentId, List.of(entry));
|
||||
updateIncident(incidentId, null, null, "NOT_FOUND", I4cCodes.STATUS_RECORD_NOT_FOUND, "Account not found");
|
||||
return;
|
||||
}
|
||||
final AccountRef account = found.get();
|
||||
final HolderDetails holder = gateway.holderDetails(account);
|
||||
final BigDecimal ledger = gateway.ledgerBalance(account);
|
||||
// Layer 0 is the complainant's own account: the specification does not ask for its holder's phone or PAN.
|
||||
final Root root = layer == 0
|
||||
? new Root(account.accountNo(), rrn, ownBank.code(), layer, disputed, ledger, null, null, null, holder.ifsc())
|
||||
: new Root(account.accountNo(), rrn, ownBank.code(), layer, disputed, ledger, holder.mobile(), holder.email(), holder.pan(),
|
||||
holder.ifsc());
|
||||
setAccount(incidentId, account);
|
||||
|
||||
if (layer == 0) {
|
||||
final Optional<MatchedTxn> debit = tracer.match(account, rrn, amount, date, Direction.DEBIT, alreadyMatched);
|
||||
if (debit.isEmpty()) {
|
||||
noMatch(requestId, incidentId, root, amount, reportedDateTime, "debit");
|
||||
return;
|
||||
}
|
||||
alreadyMatched.add(debit.get().txnId());
|
||||
final Movement movement = tracer.describeDebit(account, debit.get());
|
||||
saveFindings(requestId, incidentId, List.of(entryFor(root, account, movement, ownBank)));
|
||||
updateIncident(incidentId, debit.get().txnId(), debit.get().method(), "TRACED", I4cCodes.STATUS_SUCCESS,
|
||||
"Debit traced: " + movement.kind());
|
||||
return;
|
||||
}
|
||||
|
||||
final Optional<MatchedTxn> credit = tracer.match(account, rrn, amount, date, Direction.CREDIT, alreadyMatched);
|
||||
credit.ifPresent(c -> alreadyMatched.add(c.txnId()));
|
||||
final HoldOutcome hold = holdService.placeForIncident(requestId, incidentId, ack, layer, account, disputed);
|
||||
final List<JsonObject> entries = new ArrayList<>();
|
||||
final String now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).withNano(0).format(DATE_TIME);
|
||||
|
||||
if (!hold.holdable()) {
|
||||
entries.add(I4cResponseBuilder.putOnHold(root, BigDecimal.ZERO, now,
|
||||
"Hold not possible: account is not an active savings/current account (status " + account.status() + ")"));
|
||||
} else {
|
||||
if (hold.amountHeld().signum() > 0) {
|
||||
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())
|
||||
: 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));
|
||||
}
|
||||
// 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) {
|
||||
break;
|
||||
}
|
||||
entries.add(entryFor(root, account, movement, ownBank));
|
||||
untraced = untraced.subtract(movement.amount());
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
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.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");
|
||||
}
|
||||
|
||||
/** One debit out of the account, as the specification's transaction type that fits it. */
|
||||
private JsonObject entryFor(final Root root, final AccountRef account, final Movement movement, final Bank ownBank) {
|
||||
final BigDecimal disputed = root.disputedAmount();
|
||||
switch (movement.kind()) {
|
||||
case OUTWARD_TRANSFER -> {
|
||||
final Bank payee = banks.forIfsc(movement.payeeIfsc());
|
||||
return I4cResponseBuilder.moneyTransferTo(root, movement.reference(), payee.name(), payee.code(), movement.payeeAccountNo(),
|
||||
movement.payeeIfsc(), movement.amount(), disputed, movement.dateTime(),
|
||||
"Outward " + nz(movement.channel()) + (payee.isKnown() ? "" : "; payee bank code not in I4C Bank Code Master"));
|
||||
}
|
||||
case INTERNAL_TRANSFER -> {
|
||||
final String ifsc = Optional.ofNullable(gateway.branchIfsc(movement.payeeOfficeId())).orElse(root.ifsc());
|
||||
return I4cResponseBuilder.moneyTransferTo(root, movement.reference(), ownBank.name(), ownBank.code(),
|
||||
movement.payeeAccountNo(), ifsc, movement.amount(), disputed, movement.dateTime(),
|
||||
"Transfer to another account in this bank");
|
||||
}
|
||||
case ATM_WITHDRAWAL -> {
|
||||
return I4cResponseBuilder.withdrawalThroughAtm(root, movement.amount(), movement.dateTime(), movement.detail("atm_id"),
|
||||
movement.detail("place_of_atm"), movement.detail("atm_of_bank"), "Withdrawal through ATM");
|
||||
}
|
||||
case AEPS_WITHDRAWAL -> {
|
||||
return I4cResponseBuilder.withdrawalThroughAeps(root, movement.reference(), movement.amount(), "Withdrawal through AEPS");
|
||||
}
|
||||
case POS_WITHDRAWAL -> {
|
||||
return I4cResponseBuilder.withdrawalThroughPos(root, movement.amount(), movement.dateTime(), movement.detail("mid"),
|
||||
movement.detail("tid"), movement.detail("approval_code"), movement.detail("merchant_name"),
|
||||
movement.detail("pos_transaction_id"), "Withdrawal through POS");
|
||||
}
|
||||
default -> {
|
||||
if (movement.amount().compareTo(FIVE_HUNDRED) <= 0
|
||||
&& movement.kind() != I4cTransactionTracer.MovementKind.CHEQUE_WITHDRAWAL) {
|
||||
return I4cResponseBuilder.othersUpTo500(root, movement.amount(), describe(movement));
|
||||
}
|
||||
final BranchContact branch = gateway.branchContact(account.officeId());
|
||||
return I4cResponseBuilder.withdrawalThroughCheque(root, movement.chequeNo(), movement.dateTime(), movement.amount(),
|
||||
disputed, branch.name(), branch.name() == null ? null : "Branch Manager, " + branch.name(), branch.phone(),
|
||||
describe(movement));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(final Movement movement) {
|
||||
return switch (movement.kind()) {
|
||||
case CHEQUE_WITHDRAWAL -> "Cheque withdrawal" + ("CTS".equals(movement.channel()) ? " through CTS clearing" : " at branch");
|
||||
case CASH_WITHDRAWAL -> "Cash withdrawal at branch counter (no cheque)";
|
||||
default -> "Debit from account; channel not identified in CBS, details with bank";
|
||||
};
|
||||
}
|
||||
|
||||
private void noMatch(final Long requestId, final Long incidentId, final Root root, final BigDecimal amount, final String dateTime,
|
||||
final String what) {
|
||||
final JsonObject entry = I4cResponseBuilder.failure(root.accountNumber(), root.rrn(), root.bankId(), amount, root.disputedAmount(),
|
||||
dateTime, I4cCodes.STATUS_INVALID_RRN,
|
||||
"Invalid RRN: no " + what + " of this amount near this date on the account (" + I4cCodes.RRN_MISMATCH + ")");
|
||||
saveFindings(requestId, incidentId, List.of(entry));
|
||||
updateIncident(incidentId, null, "NONE", "NO_MATCH", I4cCodes.STATUS_INVALID_RRN, "No matching " + what);
|
||||
}
|
||||
|
||||
private void reportUnprocessed(final Map<String, Object> request, final Long requestId, final String error) {
|
||||
final List<Map<String, Object>> open = jdbcTemplate
|
||||
.queryForList("SELECT * FROM m_i4c_incident WHERE request_id = ? AND outcome IS NULL ORDER BY seq_no", requestId);
|
||||
for (final Map<String, Object> incident : open) {
|
||||
final Long incidentId = ((Number) incident.get("id")).longValue();
|
||||
final JsonObject entry = I4cResponseBuilder.failure((String) request.get("payer_account_number"), (String) incident.get("rrn"),
|
||||
banks.ownBank().code(), (BigDecimal) incident.get("amount"), (BigDecimal) incident.get("disputed_amount"),
|
||||
reportedDateTime(I4cTransactionTracer.toLocalDate(incident.get("transaction_date")),
|
||||
(String) incident.get("transaction_time")),
|
||||
I4cCodes.UNMAPPED, "Bank could not process this incident automatically; it is being handled manually");
|
||||
saveFindings(requestId, incidentId, List.of(entry));
|
||||
updateIncident(incidentId, null, null, "ERROR", I4cCodes.UNMAPPED, truncate(error, 500));
|
||||
}
|
||||
}
|
||||
|
||||
private Long respond(final Map<String, Object> request, final Long requestId, final String error) {
|
||||
final String status = jdbcTemplate.queryForObject("SELECT status FROM m_i4c_request WHERE id = ? FOR UPDATE", String.class,
|
||||
requestId);
|
||||
if (PROCESSED.equals(status)) {
|
||||
return null; // another worker already answered
|
||||
}
|
||||
final List<String> entries = jdbcTemplate.queryForList("SELECT entry_json FROM m_i4c_finding WHERE request_id = ? ORDER BY id",
|
||||
String.class, requestId);
|
||||
final List<JsonObject> transactions = entries.stream().map(e -> JsonParser.parseString(e).getAsJsonObject()).toList();
|
||||
final JsonObject response = I4cResponseBuilder.envelope((String) request.get("acknowledgement_no"), (String) request.get("job_id"),
|
||||
transactions);
|
||||
final Long outboxId = outbox.enqueue(requestId, response.toString());
|
||||
jdbcTemplate.update("UPDATE m_i4c_request SET status = ?, processed_at = ?, processing_error = ? WHERE id = ?", PROCESSED,
|
||||
Timestamp.from(Instant.now()), truncate(error, 2000), requestId);
|
||||
return outboxId;
|
||||
}
|
||||
|
||||
private void saveFindings(final Long requestId, final Long incidentId, final List<JsonObject> entries) {
|
||||
final Timestamp now = Timestamp.from(Instant.now());
|
||||
for (final JsonObject entry : entries) {
|
||||
final int typeId = entry.has("txn_type_id") ? Integer.parseInt(entry.get("txn_type_id").getAsString()) : 0;
|
||||
final String type = entry.has("txn_type") ? entry.get("txn_type").getAsString() : failureLabel(entry);
|
||||
jdbcTemplate.update(
|
||||
"""
|
||||
INSERT INTO m_i4c_finding (request_id, incident_id, txn_type_id, txn_type, amount, disputed_amount, status_code, entry_json,
|
||||
created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
requestId, incidentId, typeId, type, decimal(entry, "amount"), decimal(entry, "disputed_amount"),
|
||||
entry.has("status_code") ? entry.get("status_code").getAsString() : null, entry.toString(), now);
|
||||
}
|
||||
}
|
||||
|
||||
/** Name for an entry that is not a transaction (status_code 01 / 02 / unmapped), as the officer screens show it. */
|
||||
private static String failureLabel(final JsonObject entry) {
|
||||
final String code = entry.has("status_code") ? entry.get("status_code").getAsString() : "";
|
||||
return switch (code) {
|
||||
case I4cCodes.STATUS_RECORD_NOT_FOUND -> "Record not found";
|
||||
case I4cCodes.STATUS_INVALID_RRN -> "Invalid RRN";
|
||||
default -> "Not processed";
|
||||
};
|
||||
}
|
||||
|
||||
private void setAccount(final Long incidentId, final AccountRef account) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE m_i4c_incident SET account_type = ?, account_id = ?, account_no = ?, client_id = ?, office_id = ? WHERE id = ?",
|
||||
account.type(), account.id(), account.accountNo(), account.clientId(), account.officeId(), incidentId);
|
||||
}
|
||||
|
||||
private void updateIncident(final Long incidentId, final Long matchedTxnId, final String method, final String outcome,
|
||||
final String statusCode, final String remarks) {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_incident SET matched_txn_id = ?, match_method = ?, outcome = ?, status_code = ?, remarks = ?, processed_at = ?
|
||||
WHERE id = ?
|
||||
""", matchedTxnId, method, outcome, statusCode, truncate(remarks, 500), Timestamp.from(Instant.now()), incidentId);
|
||||
}
|
||||
|
||||
private TransactionTemplate newTransaction() {
|
||||
final TransactionTemplate tx = new TransactionTemplate(transactionManager);
|
||||
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
return tx;
|
||||
}
|
||||
|
||||
static String reportedDateTime(final LocalDate date, final String time) {
|
||||
if (date == null) {
|
||||
return "";
|
||||
}
|
||||
String t = time == null || time.isBlank() ? "00:00:00" : time.trim();
|
||||
if (t.matches("\\d{1,2}:\\d{2}")) {
|
||||
t = t + ":00";
|
||||
}
|
||||
if (!t.matches("\\d{1,2}:\\d{2}:\\d{2}")) {
|
||||
t = "00:00:00";
|
||||
}
|
||||
return date + " " + (t.length() == 7 ? "0" + t : t);
|
||||
}
|
||||
|
||||
private static BigDecimal decimal(final JsonObject entry, final String key) {
|
||||
try {
|
||||
return entry.has(key) && !entry.get(key).getAsString().isBlank() ? new BigDecimal(entry.get(key).getAsString()) : null;
|
||||
} catch (final NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String nz(final String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
return value == null || value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Who bears a fraudulent electronic banking transaction reported by our customer, under whichever RBI framework governs
|
||||
* its date.
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>RBI_2026</b> - (Urban Co-operative Banks - Responsible Business Conduct) Third Amendment Directions, 2026
|
||||
* (RBI/2026-27/172, 24.06.2026), for transactions on or after 01.01.2027: zero liability on bank negligence, or a
|
||||
* third-party breach reported within 5 calendar days (later: the bank's policy); customer negligence: the customer bears
|
||||
* the loss until reporting, with the small-value compensation (para 65.1T); resolution within 45 calendar days, 60 for
|
||||
* cross-border.</li>
|
||||
* <li><b>RBI_2017</b> - "Customer Protection - Limiting Liability of Customers in Unauthorised Electronic Banking
|
||||
* Transactions" (06.07.2017, consolidated in the 2025 directions), for earlier transactions: zero liability on bank
|
||||
* fault, or a third-party breach reported within 3 working days; limited liability for 4-7 working days; credit
|
||||
* (shadow reversal) within 10 working days of the report; resolution within 90 days.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class I4cCustomerLiability {
|
||||
|
||||
public static final LocalDate RBI_2026_FROM = LocalDate.of(2027, 1, 1);
|
||||
/** Para 65.1U: compensation covers frauds up to one year from the effective date. */
|
||||
public static final LocalDate COMPENSATION_UNTIL = LocalDate.of(2027, 12, 31);
|
||||
public static final Set<String> CATEGORIES = Set.of("BANK_NEGLIGENCE", "THIRD_PARTY_BREACH", "CUSTOMER_NEGLIGENCE");
|
||||
public static final BigDecimal COMPENSATION_MAX_LOSS = new BigDecimal("50000");
|
||||
public static final BigDecimal COMPENSATION_CAP = new BigDecimal("25000");
|
||||
public static final BigDecimal COMPENSATION_RATE = new BigDecimal("0.85");
|
||||
|
||||
private I4cCustomerLiability() {}
|
||||
|
||||
public enum Regime {
|
||||
RBI_2017, RBI_2026
|
||||
}
|
||||
|
||||
/** The outcome of an assessment: zero liability or not, an explanation, and the due dates. */
|
||||
public record Assessment(Regime regime, boolean zeroLiability, BigDecimal limitedCap, String note, LocalDate shadowReversalDue,
|
||||
LocalDate resolutionDue) {
|
||||
}
|
||||
|
||||
/** A compensation decision and how it is shared (para 65.1T). */
|
||||
public record Compensation(boolean eligible, String reason, BigDecimal amount, BigDecimal rbi, BigDecimal ownBank,
|
||||
BigDecimal beneficiaryBank) {
|
||||
}
|
||||
|
||||
public static Regime regimeFor(final LocalDate txnDate) {
|
||||
return txnDate.isBefore(RBI_2026_FROM) ? Regime.RBI_2017 : Regime.RBI_2026;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param accountKind
|
||||
* SAVINGS, CURRENT or BSBD, for the 2017 limited-liability cap
|
||||
*/
|
||||
public static Assessment assess(final String category, final LocalDate txnDate, final LocalDate reportedOn, final boolean crossBorder,
|
||||
final String accountKind) {
|
||||
final Regime regime = regimeFor(txnDate);
|
||||
if (regime == Regime.RBI_2026) {
|
||||
final long days = ChronoUnit.DAYS.between(txnDate, reportedOn);
|
||||
final LocalDate resolution = reportedOn.plusDays(crossBorder ? 60 : 45);
|
||||
return switch (category) {
|
||||
case "BANK_NEGLIGENCE" -> new Assessment(regime, true, null, "Bank negligence: zero liability (para 65.1L)", null, resolution);
|
||||
case "THIRD_PARTY_BREACH" -> days <= 5
|
||||
? new Assessment(regime, true, null, "Third-party breach reported in " + days + " calendar days: zero liability (65.1M)",
|
||||
null, resolution)
|
||||
: new Assessment(regime, false, null,
|
||||
"Third-party breach reported after 5 calendar days: liability as per the bank's policy (65.1M)", null, resolution);
|
||||
default -> new Assessment(regime, false, null,
|
||||
"Customer negligence: the customer bears the loss until reporting, subject to compensation (65.1N, 65.1T)", null,
|
||||
resolution);
|
||||
};
|
||||
}
|
||||
final int workingDays = workingDaysBetween(txnDate, reportedOn);
|
||||
final LocalDate shadow = addWorkingDays(reportedOn, 10);
|
||||
final LocalDate resolution = reportedOn.plusDays(90);
|
||||
return switch (category) {
|
||||
case "BANK_NEGLIGENCE" -> new Assessment(regime, true, null, "Bank's fault: zero liability (2017 framework)", shadow, resolution);
|
||||
case "THIRD_PARTY_BREACH" -> {
|
||||
if (workingDays <= 3) {
|
||||
yield new Assessment(regime, true, null, "Third-party breach reported in " + workingDays + " working days: zero liability",
|
||||
shadow, resolution);
|
||||
}
|
||||
if (workingDays <= 7) {
|
||||
final BigDecimal cap = "BSBD".equals(accountKind) ? new BigDecimal("5000")
|
||||
: "CURRENT".equals(accountKind) ? new BigDecimal("25000") : new BigDecimal("10000");
|
||||
yield new Assessment(regime, false, cap, "Reported in " + workingDays
|
||||
+ " working days: customer liability limited to the transaction value or Rs " + cap.toPlainString(), shadow,
|
||||
resolution);
|
||||
}
|
||||
yield new Assessment(regime, false, null, "Reported after 7 working days: liability as per the bank's policy", shadow,
|
||||
resolution);
|
||||
}
|
||||
default -> new Assessment(regime, false, null, "Customer negligence: the customer bears the loss until reporting", null,
|
||||
resolution);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Para 65.1T: an individual's customer-negligence loss of up to Rs 50,000, reported to the bank and to NCRP / 1930
|
||||
* within 5 calendar days, compensated once in a lifetime with 85% of the net loss or Rs 25,000, whichever is less.
|
||||
*/
|
||||
public static Compensation compensation(final String category, final LocalDate txnDate, final LocalDate reportedToBank,
|
||||
final LocalDate reportedToNcrp, final BigDecimal grossLoss, final BigDecimal recovered, final boolean crossBorder,
|
||||
final boolean alreadyCompensated) {
|
||||
final String why;
|
||||
if (regimeFor(txnDate) != Regime.RBI_2026) {
|
||||
why = "The compensation scheme covers frauds from 01.01.2027 only";
|
||||
} else if (txnDate.isAfter(COMPENSATION_UNTIL)) {
|
||||
why = "The compensation scheme covers frauds up to one year from 01.01.2027";
|
||||
} else if (!"CUSTOMER_NEGLIGENCE".equals(category)) {
|
||||
why = "Compensation is for customer-negligence losses; this case is a reversal";
|
||||
} else if (grossLoss == null || grossLoss.signum() <= 0 || grossLoss.compareTo(COMPENSATION_MAX_LOSS) > 0) {
|
||||
why = "The gross loss must be up to Rs 50,000";
|
||||
} else if (reportedToBank == null || reportedToNcrp == null || ChronoUnit.DAYS.between(txnDate, reportedToBank) > 5
|
||||
|| ChronoUnit.DAYS.between(txnDate, reportedToNcrp) > 5) {
|
||||
why = "It must be reported to the bank and to NCRP / 1930 within 5 calendar days";
|
||||
} else if (alreadyCompensated) {
|
||||
why = "Compensation is once in a lifetime and this customer has had it";
|
||||
} else {
|
||||
why = null;
|
||||
}
|
||||
if (why != null) {
|
||||
return new Compensation(false, why, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
}
|
||||
final BigDecimal net = grossLoss.subtract(recovered == null ? BigDecimal.ZERO : recovered).max(BigDecimal.ZERO);
|
||||
final BigDecimal pct = net.multiply(COMPENSATION_RATE).setScale(2, RoundingMode.HALF_UP);
|
||||
if (pct.compareTo(COMPENSATION_CAP) >= 0) {
|
||||
// Rs 29,412 and above: a flat Rs 25,000, in the directions' fixed shares.
|
||||
return crossBorder
|
||||
? new Compensation(true, "Rs 25,000 (cap)", COMPENSATION_CAP, new BigDecimal("19118"), new BigDecimal("5882"), BigDecimal.ZERO)
|
||||
: new Compensation(true, "Rs 25,000 (cap)", COMPENSATION_CAP, new BigDecimal("19118"), new BigDecimal("2941"),
|
||||
new BigDecimal("2941"));
|
||||
}
|
||||
// Below Rs 29,412: 85% of the net loss, shared 65 / 10 / 10 (cross-border 65 / 20) per cent of the net loss.
|
||||
final BigDecimal own = net.multiply(new BigDecimal(crossBorder ? "0.20" : "0.10")).setScale(2, RoundingMode.HALF_UP);
|
||||
final BigDecimal beneficiary = crossBorder ? BigDecimal.ZERO : net.multiply(new BigDecimal("0.10")).setScale(2, RoundingMode.HALF_UP);
|
||||
final BigDecimal rbi = pct.subtract(own).subtract(beneficiary);
|
||||
return new Compensation(true, "85% of the net loss of Rs " + net.toPlainString(), pct, rbi, own, beneficiary);
|
||||
}
|
||||
|
||||
static int workingDaysBetween(final LocalDate from, final LocalDate to) {
|
||||
int n = 0;
|
||||
for (LocalDate d = from.plusDays(1); !d.isAfter(to); d = d.plusDays(1)) {
|
||||
if (d.getDayOfWeek() != DayOfWeek.SATURDAY && d.getDayOfWeek() != DayOfWeek.SUNDAY) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static LocalDate addWorkingDays(final LocalDate from, final int days) {
|
||||
LocalDate d = from;
|
||||
int left = days;
|
||||
while (left > 0) {
|
||||
d = d.plusDays(1);
|
||||
if (d.getDayOfWeek() != DayOfWeek.SATURDAY && d.getDayOfWeek() != DayOfWeek.SUNDAY) {
|
||||
left--;
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Orders, notices, bonds, FIRs, KYC and statements behind an I4C hold, a GRM grievance or an MRM restoration. Kept in
|
||||
* the database with a SHA-256, never changed once uploaded, so the record shows exactly what the decision rested on.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cDocumentService {
|
||||
|
||||
public static final long MAX_BYTES = 5L * 1024 * 1024;
|
||||
public static final Set<String> OWNER_TYPES = Set.of("HOLD", "GRIEVANCE", "RESTORATION");
|
||||
public static final Set<String> DOC_TYPES = Set.of("IO_ORDER", "COURT_ORDER", "GRM_ORDER", "BNSS_106_NOTICE", "INDEMNITY_BOND", "FIR",
|
||||
"KYC", "APPLICATION", "PHOTO", "BANK_STATEMENT", "EDD_REPORT", "PAN", "OTHER");
|
||||
public static final Set<String> CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** A stored file: what the download endpoint sends back. */
|
||||
public record StoredFile(String fileName, String contentType, byte[] content) {
|
||||
}
|
||||
|
||||
public Long upload(final String ownerType, final Long ownerId, final String docType, final String fileName, final String contentType,
|
||||
final String base64Content, final String remarks, final String uploadedBy) {
|
||||
final String owner = upper(ownerType);
|
||||
if (!OWNER_TYPES.contains(owner)) {
|
||||
throw error("document.owner", "Documents attach to a HOLD, GRIEVANCE or RESTORATION.");
|
||||
}
|
||||
final String type = upper(docType);
|
||||
if (!DOC_TYPES.contains(type)) {
|
||||
throw error("document.type", "Document type must be one of " + DOC_TYPES + ".");
|
||||
}
|
||||
final String mime = contentType == null ? "" : contentType.trim().toLowerCase(Locale.ROOT);
|
||||
if (!CONTENT_TYPES.contains(mime)) {
|
||||
throw error("document.format", "Only PDF, JPEG and PNG files can be attached.");
|
||||
}
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
throw error("document.name", "The file name is required.");
|
||||
}
|
||||
requireOwner(owner, ownerId);
|
||||
final byte[] content;
|
||||
try {
|
||||
content = Base64.getDecoder().decode(base64Content == null ? "" : base64Content.replaceAll("\\s", ""));
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw error("document.encoding", "The file content must be Base64 encoded.", e);
|
||||
}
|
||||
if (content.length == 0) {
|
||||
throw error("document.empty", "The file is empty.");
|
||||
}
|
||||
if (content.length > MAX_BYTES) {
|
||||
throw error("document.size", "A document can be at most 5 MB.");
|
||||
}
|
||||
if (!matchesType(mime, content)) {
|
||||
throw error("document.mismatch", "The file content does not look like a " + mime + " file.");
|
||||
}
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_document (owner_type, owner_id, doc_type, file_name, content_type, size_bytes, sha256, content, remarks,
|
||||
uploaded_by, uploaded_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, owner, ownerId, type, safeName(fileName), mime, (long) content.length, sha256(content), content,
|
||||
trim(remarks, 500), trim(uploadedBy, 100), Timestamp.from(Instant.now()));
|
||||
}
|
||||
|
||||
/** The documents of one record, without their content. */
|
||||
public List<Map<String, Object>> list(final String ownerType, final Long ownerId) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT id, owner_type, owner_id, doc_type, file_name, content_type, size_bytes, sha256, remarks, uploaded_by, uploaded_at
|
||||
FROM m_i4c_document WHERE owner_type = ? AND owner_id = ? ORDER BY id DESC
|
||||
""", upper(ownerType), ownerId);
|
||||
}
|
||||
|
||||
/** Which of {@code required} document types the record does not have yet. */
|
||||
public List<String> missing(final String ownerType, final Long ownerId, final List<String> required) {
|
||||
final List<String> present = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT doc_type FROM m_i4c_document WHERE owner_type = ? AND owner_id = ?", String.class, upper(ownerType), ownerId);
|
||||
return required.stream().filter(t -> !present.contains(t)).toList();
|
||||
}
|
||||
|
||||
public StoredFile download(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT file_name, content_type, content FROM m_i4c_document WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("document.not.found", "Document " + id + " not found.");
|
||||
}
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
return new StoredFile((String) r.get("file_name"), (String) r.get("content_type"), (byte[]) r.get("content"));
|
||||
}
|
||||
|
||||
private void requireOwner(final String owner, final Long ownerId) {
|
||||
final String table = switch (owner) {
|
||||
case "HOLD" -> "m_i4c_hold";
|
||||
case "GRIEVANCE" -> "m_i4c_grievance";
|
||||
default -> "m_i4c_restoration";
|
||||
};
|
||||
final Integer n = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE id = ?", Integer.class, ownerId);
|
||||
if (n == null || n == 0) {
|
||||
throw error("document.owner.missing", owner + " " + ownerId + " not found.");
|
||||
}
|
||||
}
|
||||
|
||||
/** First bytes of the file, so a renamed executable is not stored as a PDF. */
|
||||
static boolean matchesType(final String mime, final byte[] c) {
|
||||
return switch (mime) {
|
||||
case "application/pdf" -> c.length > 4 && new String(c, 0, 5, StandardCharsets.US_ASCII).equals("%PDF-");
|
||||
case "image/png" -> c.length > 8 && (c[0] & 0xFF) == 0x89 && c[1] == 'P' && c[2] == 'N' && c[3] == 'G';
|
||||
case "image/jpeg" -> c.length > 3 && (c[0] & 0xFF) == 0xFF && (c[1] & 0xFF) == 0xD8 && (c[2] & 0xFF) == 0xFF;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
static String safeName(final String name) {
|
||||
final String base = name.replace('\\', '/');
|
||||
final String last = base.substring(base.lastIndexOf('/') + 1).replaceAll("[^A-Za-z0-9._ -]", "_").trim();
|
||||
final String out = last.isEmpty() ? "document" : last;
|
||||
return out.length() <= 255 ? out : out.substring(out.length() - 255);
|
||||
}
|
||||
|
||||
private static String sha256(final byte[] content) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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.util.concurrent.ThreadPoolExecutor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* A small pool of its own for I4C processing, so a burst of complaints (a fraud ring reported at once) neither waits
|
||||
* behind nor starves the rest of the CBS's background work. When the queue is full the work runs on the caller's thread
|
||||
* rather than being dropped: slower acks are better than a complaint nobody processes until the next job run.
|
||||
*/
|
||||
@Configuration
|
||||
public class I4cExecutorConfig {
|
||||
|
||||
public static final String EXECUTOR = "i4cExecutor";
|
||||
|
||||
@Bean(name = EXECUTOR)
|
||||
public TaskExecutor i4cExecutor() {
|
||||
final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(4);
|
||||
executor.setQueueCapacity(500);
|
||||
executor.setThreadNamePrefix("i4c-");
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
executor.setAwaitTerminationSeconds(30);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* 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.Duration;
|
||||
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 org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The bank's record of a GRM grievance on an I4C hold, following the GRM user manual:
|
||||
*
|
||||
* <pre>
|
||||
* customer at branch -> EDD -> DRAFT (bank raises it within 7 days) -> (GR id, KYC / application / photo / statement)
|
||||
* -> NODAL_REVIEW -> nodal approves -> WITH_IO (15 days) -> [IO investigates: IO_INVESTIGATION, 15 days]
|
||||
* -> IO does not act in time -> escalated to the District GRO (SOP 9.1)
|
||||
* -> IO decides: allowed -> order carried out on the account through maker-checker -> CLOSED
|
||||
* rejected -> branch escalates -> WITH_DGRO (15 days) -> rejected -> WITH_SGRO (15 days) -> rejected -> COURT
|
||||
* </pre>
|
||||
*
|
||||
* An allowing order sets a 48-hour compliance deadline (the manual's window for the IO to instruct the bank after a
|
||||
* DGRO / SGRO order, used here as the bank's own target too); it ends when the linked request is approved.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cGrievanceService {
|
||||
|
||||
public static final Set<String> CUSTOMER_REQUESTS = Set.of("REMOVE_HOLD", "UNFREEZE", "RESTORE_DIGITAL_SERVICES");
|
||||
public static final Set<String> RISKS = Set.of("LOW", "MEDIUM", "HIGH");
|
||||
public static final Set<String> RECOMMENDATIONS = Set.of("SUPPORT", "NOT_SUPPORT");
|
||||
/** GRM manual, branch entry form: KYC document, application, photograph and bank statement of the account holder. */
|
||||
public static final List<String> REQUIRED_DOCUMENTS = List.of("KYC", "APPLICATION", "PHOTO", "BANK_STATEMENT");
|
||||
public static final Set<String> DECISIONS = Set.of("REMOVE_HOLD", "UNFREEZE_HOLD_CONTINUES", "UNFREEZE_AND_REMOVE_HOLD", "PARTIAL_HOLD",
|
||||
"RESTORE_DIGITAL", "REJECTED_CONTINUE", "SEEK_INFO");
|
||||
public static final Set<String> NOTE_EVENTS = Set.of("VC_REQUESTED", "VC_DONE", "VC_NOT_REQUIRED", "INFO_SOUGHT", "INFO_SENT", "NOTE");
|
||||
|
||||
/** 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) {
|
||||
final Map<String, Object> hold = holdService.loadHold(holdId);
|
||||
if (!I4cHoldService.ACTIVE.equals(hold.get("status"))) {
|
||||
throw error("grievance.hold", "A grievance can be raised only on an active hold.");
|
||||
}
|
||||
final String request = upper(customerRequest);
|
||||
if (!CUSTOMER_REQUESTS.contains(request)) {
|
||||
throw error("grievance.request", "The customer's request must be one of " + CUSTOMER_REQUESTS + ".");
|
||||
}
|
||||
final Integer open = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_grievance WHERE hold_id = ? AND stage <> 'CLOSED'", Integer.class, holdId);
|
||||
if (open != null && open > 0) {
|
||||
throw error("grievance.open", "This hold already has an open grievance.");
|
||||
}
|
||||
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, due_at, created_by,
|
||||
created_at, updated_at)
|
||||
VALUES (?,?,?,'DRAFT','OPEN',?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", 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;
|
||||
}
|
||||
|
||||
/** Enhanced due diligence the branch does before taking the grievance to GRM. */
|
||||
@Transactional
|
||||
public void recordEdd(final Long id, final Boolean kycVerified, final String sourceOfFunds, final String customerStatement,
|
||||
final String risk, final String recommendation, final AppUser user) {
|
||||
final Map<String, Object> g = load(id);
|
||||
requireStage(g, "DRAFT");
|
||||
if (!Boolean.TRUE.equals(kycVerified) && !Boolean.FALSE.equals(kycVerified)) {
|
||||
throw error("edd.kyc", "Say whether the KYC was verified.");
|
||||
}
|
||||
if (blank(sourceOfFunds)) {
|
||||
throw error("edd.source", "Record the customer's explanation of the disputed credit (source of funds).");
|
||||
}
|
||||
final String r = upper(risk);
|
||||
if (!RISKS.contains(r)) {
|
||||
throw error("edd.risk", "Risk must be LOW, MEDIUM or HIGH.");
|
||||
}
|
||||
final String rec = upper(recommendation);
|
||||
if (!RECOMMENDATIONS.contains(rec)) {
|
||||
throw error("edd.recommendation", "Recommendation must be SUPPORT or NOT_SUPPORT.");
|
||||
}
|
||||
final Timestamp now = now();
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_grievance SET edd_done = true, edd_kyc_verified = ?, edd_source_of_funds = ?, edd_customer_statement = ?, edd_risk = ?,
|
||||
edd_recommendation = ?, edd_by = ?, edd_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", kycVerified, trim(sourceOfFunds, 2000), trim(customerStatement, 2000), r, rec, name(user), now, now, id);
|
||||
event(id, "EDD_DONE", "DRAFT", "Risk " + r + ", bank " + (rec.equals("SUPPORT") ? "supports" : "does not support") + " the request",
|
||||
user);
|
||||
}
|
||||
|
||||
/** Raised on GRM: records the GR id; needs the EDD and the documents the GRM entry form asks for. */
|
||||
@Transactional
|
||||
public void submit(final Long id, final String grievanceNo, final AppUser user) {
|
||||
final Map<String, Object> g = load(id);
|
||||
requireStage(g, "DRAFT");
|
||||
if (!Boolean.TRUE.equals(g.get("edd_done"))) {
|
||||
throw error("grievance.edd", "Complete the enhanced due diligence first.");
|
||||
}
|
||||
if (blank(grievanceNo)) {
|
||||
throw error("grievance.no", "Enter the grievance id GRM gave (e.g. GR003260316001).");
|
||||
}
|
||||
final List<String> missing = documents.missing("GRIEVANCE", id, REQUIRED_DOCUMENTS);
|
||||
if (!missing.isEmpty()) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** The bank's GRM nodal officer approves (on to the IO) or rejects the grievance. */
|
||||
@Transactional
|
||||
public void nodal(final Long id, final boolean approve, final String remarks, final AppUser user) {
|
||||
final Map<String, Object> g = load(id);
|
||||
requireStage(g, "NODAL_REVIEW");
|
||||
if (approve) {
|
||||
move(id, "WITH_IO", IO_WINDOW, "NODAL_APPROVED", remarks, user);
|
||||
return;
|
||||
}
|
||||
if (blank(remarks)) {
|
||||
throw error("grievance.reason", "A reason is required to reject.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_grievance SET status = 'REJECTED' WHERE id = ?", id);
|
||||
move(id, "CLOSED", null, "NODAL_REJECTED", remarks, user);
|
||||
}
|
||||
|
||||
/** Something that happened on GRM without changing the stage: VC requested / held, information sought or sent. */
|
||||
@Transactional
|
||||
public void note(final Long id, final String eventType, final String remarks, final AppUser user) {
|
||||
final Map<String, Object> g = load(id);
|
||||
final String type = upper(eventType);
|
||||
if (!NOTE_EVENTS.contains(type)) {
|
||||
throw error("grievance.event", "Event must be one of " + NOTE_EVENTS + ".");
|
||||
}
|
||||
if ("CLOSED".equals(g.get("stage"))) {
|
||||
throw error("grievance.closed", "This grievance is closed.");
|
||||
}
|
||||
event(id, type, (String) g.get("stage"), remarks, user);
|
||||
}
|
||||
|
||||
/** The IO is investigating (notice, VC, Samanvaya): another 15 days. */
|
||||
@Transactional
|
||||
public void investigation(final Long id, final String remarks, final AppUser user) {
|
||||
final Map<String, Object> g = load(id);
|
||||
requireStage(g, "WITH_IO");
|
||||
move(id, "IO_INVESTIGATION", REVIEW_WINDOW, "INVESTIGATION", remarks, user);
|
||||
}
|
||||
|
||||
/** The IO's, DGRO's or SGRO's decision, as GRM shows it. */
|
||||
@Transactional
|
||||
public void decide(final Long id, final String level, final String decision, final BigDecimal keepAmount, final String orderRef,
|
||||
final LocalDate orderDate, final String remarks, final AppUser user) {
|
||||
final Map<String, Object> g = load(id);
|
||||
final String lvl = upper(level);
|
||||
final String stage = (String) g.get("stage");
|
||||
final boolean levelMatches = switch (lvl) {
|
||||
case "IO" -> "WITH_IO".equals(stage) || "IO_INVESTIGATION".equals(stage);
|
||||
case "DGRO" -> "WITH_DGRO".equals(stage);
|
||||
case "SGRO" -> "WITH_SGRO".equals(stage);
|
||||
default -> false;
|
||||
};
|
||||
if (!levelMatches) {
|
||||
throw error("grievance.level", "At stage " + stage + " the decision comes from " + expectedLevel(stage) + ".");
|
||||
}
|
||||
if (!"OPEN".equals(g.get("status"))) {
|
||||
throw error("grievance.status", "This grievance is " + g.get("status") + "; escalate it before recording another decision.");
|
||||
}
|
||||
final String d = upper(decision);
|
||||
if (!DECISIONS.contains(d)) {
|
||||
throw error("grievance.decision", "Decision must be one of " + DECISIONS + ".");
|
||||
}
|
||||
if ("SEEK_INFO".equals(d)) {
|
||||
event(id, "INFO_SOUGHT", stage, lvl + ": " + (remarks == null ? "additional information sought" : remarks), user);
|
||||
return;
|
||||
}
|
||||
final Timestamp now = now();
|
||||
if ("REJECTED_CONTINUE".equals(d)) {
|
||||
if (blank(remarks)) {
|
||||
throw error("grievance.reason", "Record the reason the " + lvl + " gave.");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_grievance SET status = 'REJECTED', decision = ?, decision_level = ?, decision_remarks = ?, decision_date = ?,
|
||||
due_at = NULL, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", d, lvl, trim(remarks, 2000), orderDate == null ? null : Date.valueOf(orderDate), now, id);
|
||||
event(id, "REJECTED", stage, lvl + " rejected: " + remarks, user);
|
||||
return;
|
||||
}
|
||||
if (blank(orderRef)) {
|
||||
throw error("grievance.order", "Enter the order number; the order copy must be attached as a GRM_ORDER / IO_ORDER document.");
|
||||
}
|
||||
final List<String> orderDocs = documents.missing("GRIEVANCE", id, List.of("GRM_ORDER"));
|
||||
final List<String> ioDocs = documents.missing("GRIEVANCE", id, List.of("IO_ORDER"));
|
||||
if (!orderDocs.isEmpty() && !ioDocs.isEmpty()) {
|
||||
throw error("grievance.order.copy", "Attach the signed and stamped order copy (GRM_ORDER or IO_ORDER) first.");
|
||||
}
|
||||
final Map<String, Object> hold = holdService.loadHold(((Number) g.get("hold_id")).longValue());
|
||||
final BigDecimal held = (BigDecimal) hold.get("amount_held");
|
||||
if ("PARTIAL_HOLD".equals(d)) {
|
||||
if (keepAmount == null || keepAmount.signum() < 0 || keepAmount.compareTo(held) >= 0) {
|
||||
throw error("grievance.keep", "Enter the amount the order keeps on hold; it must be less than the " + held.toPlainString()
|
||||
+ " held now.");
|
||||
}
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_grievance SET status = 'ALLOWED', decision = ?, decision_level = ?, decision_keep_amount = ?, decision_order_ref = ?,
|
||||
decision_date = ?, decision_remarks = ?, due_at = NULL, compliance_due_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", d, lvl, "PARTIAL_HOLD".equals(d) ? keepAmount : null, trim(orderRef, 200),
|
||||
orderDate == null ? null : Date.valueOf(orderDate), trim(remarks, 2000), Timestamp.from(Instant.now().plus(COMPLIANCE_WINDOW)),
|
||||
now, id);
|
||||
event(id, "DECISION", stage, lvl + " ordered " + d + " (" + orderRef.trim() + ")", user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the maker request that carries out an allowing order on the account; it closes the grievance when a checker
|
||||
* approves it.
|
||||
*/
|
||||
@Transactional
|
||||
public Long raiseAction(final Long id, final AppUser maker) {
|
||||
final Map<String, Object> g = load(id);
|
||||
if (!"ALLOWED".equals(g.get("status")) || "CLOSED".equals(g.get("stage"))) {
|
||||
throw error("grievance.not.allowed", "Only an allowed grievance that is not yet carried out can raise a request.");
|
||||
}
|
||||
final Long holdId = ((Number) g.get("hold_id")).longValue();
|
||||
final Map<String, Object> hold = holdService.loadHold(holdId);
|
||||
final String decision = (String) g.get("decision");
|
||||
if ("RESTORE_DIGITAL".equals(decision) && !Boolean.TRUE.equals(hold.get("digital_suspended"))) {
|
||||
jdbcTemplate.update("UPDATE m_i4c_grievance SET compliance_due_at = NULL, updated_at = ? WHERE id = ?", now(), id);
|
||||
move(id, "CLOSED", null, "CARRIED_OUT", "Digital services were not suspended for this account; no change needed", maker);
|
||||
return null;
|
||||
}
|
||||
if ("UNFREEZE_HOLD_CONTINUES".equals(decision) && !Boolean.TRUE.equals(hold.get("debit_frozen"))) {
|
||||
// Nothing to change: debits were never frozen for this hold, and the order keeps the hold.
|
||||
jdbcTemplate.update("UPDATE m_i4c_grievance SET compliance_due_at = NULL, updated_at = ? WHERE id = ?", now(), id);
|
||||
move(id, "CLOSED", null, "CARRIED_OUT", "Order keeps the hold; debits were not frozen, so no change on the account", maker);
|
||||
return null;
|
||||
}
|
||||
final String action;
|
||||
BigDecimal amount = null;
|
||||
switch (decision) {
|
||||
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"));
|
||||
}
|
||||
default -> throw error("grievance.decision", "Decision " + decision + " does not change the account.");
|
||||
}
|
||||
final Date orderDate = (Date) g.get("decision_date");
|
||||
final Long requestId = holdService.requestAction(holdId, action, "GRM_DECISION", (String) g.get("decision_order_ref"),
|
||||
orderDate == null ? null : orderDate.toLocalDate(), "GRM " + g.get("decision_level"),
|
||||
"GRM grievance " + (g.get("grievance_no") == null ? id : g.get("grievance_no")), amount, null, id, maker);
|
||||
jdbcTemplate.update("UPDATE m_i4c_grievance SET action_request_id = ?, updated_at = ? WHERE id = ?", requestId, now(), id);
|
||||
event(id, "ACTION_RAISED", (String) g.get("stage"), action + " request " + requestId + " raised for checker approval", maker);
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/** After a rejection: on to the District GRO, then the State GRO, then the courts. */
|
||||
@Transactional
|
||||
public void escalate(final Long id, final String remarks, final AppUser user) {
|
||||
final Map<String, Object> g = load(id);
|
||||
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";
|
||||
case "WITH_SGRO" -> "CLOSED";
|
||||
default -> throw error("grievance.escalate", "Stage " + stage + " cannot be escalated.");
|
||||
};
|
||||
jdbcTemplate.update("UPDATE m_i4c_grievance SET status = ?, decision = NULL, decision_level = NULL WHERE id = ?",
|
||||
"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)
|
||||
: (ioSilent ? "IO did not decide within 15 days; " : "") + "Review raised to "
|
||||
+ ("WITH_DGRO".equals(next) ? "District" : "State") + " GRO. " + nz(remarks),
|
||||
user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void withdraw(final Long id, final String remarks, final AppUser user) {
|
||||
final Map<String, Object> g = load(id);
|
||||
if ("CLOSED".equals(g.get("stage"))) {
|
||||
throw error("grievance.closed", "This grievance is closed.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_grievance SET status = 'WITHDRAWN' WHERE id = ?", id);
|
||||
move(id, "CLOSED", null, "WITHDRAWN", remarks, user);
|
||||
}
|
||||
|
||||
public Map<String, Object> load(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_grievance WHERE id = ? FOR UPDATE", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("grievance.not.found", "Grievance " + id + " not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private void move(final Long id, final String stage, final Duration window, final String eventType, final String remarks,
|
||||
final AppUser user) {
|
||||
final Timestamp now = now();
|
||||
jdbcTemplate.update("UPDATE m_i4c_grievance SET stage = ?, stage_entered_at = ?, due_at = ?, updated_at = ? WHERE id = ?", stage, now,
|
||||
window == null ? null : Timestamp.from(now.toInstant().plus(window)), now, id);
|
||||
event(id, eventType, stage, remarks, user);
|
||||
}
|
||||
|
||||
private void event(final Long id, final String type, final String stage, final String remarks, final AppUser user) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_grievance_event (grievance_id, event_type, stage, remarks, actor, created_at) VALUES (?,?,?,?,?,?)
|
||||
""", id, type, stage, trim(remarks, 2000), name(user), now());
|
||||
}
|
||||
|
||||
static String expectedLevel(final String stage) {
|
||||
return switch (stage) {
|
||||
case "WITH_IO", "IO_INVESTIGATION" -> "the IO";
|
||||
case "WITH_DGRO" -> "the District GRO";
|
||||
case "WITH_SGRO" -> "the State GRO";
|
||||
default -> "no one (the grievance is not with an officer)";
|
||||
};
|
||||
}
|
||||
|
||||
private static void requireStage(final Map<String, Object> g, final String stage) {
|
||||
if (!stage.equals(g.get("stage"))) {
|
||||
throw error("grievance.stage", "This can be done only at stage " + stage + "; the grievance is at " + g.get("stage") + ".");
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return new PlatformDataIntegrityException("error.msg.i4c." + code, message);
|
||||
}
|
||||
}
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* 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.Map;
|
||||
import java.util.Optional;
|
||||
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.config.I4cProperties;
|
||||
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.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* The register of holds placed for I4C, and everything done to them afterwards.
|
||||
*
|
||||
* Rules applied, from the MHA SOP for NCRP-CFCFRMS (02.01.2026) and RBI's draft KYC Amendment Directions on debit holds
|
||||
* (Sep 2026):
|
||||
* <ul>
|
||||
* <li>Only the disputed amount is held, never the whole balance; a full debit freeze is a separate, approved step.</li>
|
||||
* <li>When the balance is short of the disputed amount, what is there is held and the rest is a <em>prospective
|
||||
* hold</em>, topped up from later credits by the I4C Hold Review job until the disputed amount is covered.</li>
|
||||
* <li>Every hold carries a review date (90 days by default) and a customer-intimation deadline (the draft directions:
|
||||
* by the end of the next day), both tracked in this register so nothing is held silently or indefinitely.</li>
|
||||
* <li>Placing is automatic. Releasing, and freezing or unfreezing debits, need a maker and a different checker, and
|
||||
* record the order or reason (IO order, GRM/MRM decision, court order, 90-day review).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cHoldService {
|
||||
|
||||
public static final String ACTIVE = "ACTIVE";
|
||||
public static final String RELEASED = "RELEASED";
|
||||
public static final String NOT_HOLDABLE = "NOT_HOLDABLE";
|
||||
|
||||
public static final String ACTION_RELEASE = "RELEASE";
|
||||
public static final String ACTION_FREEZE = "FREEZE";
|
||||
public static final String ACTION_UNFREEZE = "UNFREEZE";
|
||||
/** GRM "Unfreeze and remove put on hold": a full release, which also lifts a debit freeze. */
|
||||
public static final String ACTION_UNFREEZE_AND_RELEASE = "UNFREEZE_AND_RELEASE";
|
||||
/** MRM: release the amount and send it to the victim, as one step. */
|
||||
public static final String ACTION_RESTORE = "RESTORE";
|
||||
/** SOP: suspend digital banking on a repeatedly reported account, and restore it (GRM "restore digital services"). */
|
||||
public static final String ACTION_SUSPEND_DIGITAL = "SUSPEND_DIGITAL";
|
||||
public static final String ACTION_RESTORE_DIGITAL = "RESTORE_DIGITAL";
|
||||
public static final Set<String> ACTIONS = Set.of(ACTION_RELEASE, ACTION_FREEZE, ACTION_UNFREEZE, ACTION_UNFREEZE_AND_RELEASE,
|
||||
ACTION_RESTORE, ACTION_SUSPEND_DIGITAL, ACTION_RESTORE_DIGITAL);
|
||||
public static final Set<String> REASON_TYPES = Set.of("IO_ORDER", "GRM_DECISION", "MRM_RESTORATION", "COURT_ORDER", "REVIEW_90_DAYS",
|
||||
"LEA_DIRECTION", "WRONG_ACCOUNT", "OTHER");
|
||||
|
||||
private static final String PENDING = "PENDING";
|
||||
private static final String APPROVED = "APPROVED";
|
||||
private static final String REJECTED = "REJECTED";
|
||||
private static final String FAILED = "FAILED";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cAccountGateway gateway;
|
||||
private final I4cProperties properties;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final I4cRestorationExecutor restorationExecutor;
|
||||
private final I4cRiskService riskService;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds up to the disputed amount on the account for this incident. Idempotent: the register keys on the incident,
|
||||
* so a reprocessed complaint finds its hold and does not place a second one. Runs in the caller's transaction.
|
||||
*/
|
||||
public HoldOutcome placeForIncident(final Long requestId, final Long incidentId, final String acknowledgementNo, final int layer,
|
||||
final AccountRef account, final BigDecimal disputedAmount) {
|
||||
final Optional<Map<String, Object>> existing = findByIncident(incidentId).or(() -> findSameIncident(incidentId, acknowledgementNo,
|
||||
account, layer));
|
||||
if (existing.isPresent()) {
|
||||
// A reprocess, or I4C pushing the same reported transaction again in a new request: never a second hold.
|
||||
final Map<String, Object> h = existing.get();
|
||||
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()));
|
||||
final Instant intimationDue = now.plusSeconds(3600L * Math.max(1, properties.getHold().getIntimationHours()));
|
||||
|
||||
BigDecimal held = BigDecimal.ZERO;
|
||||
Long holdTxnId = null;
|
||||
if (account.holdable()) {
|
||||
final BigDecimal available = gateway.withdrawable(account);
|
||||
final BigDecimal toHold = available.min(disputedAmount);
|
||||
if (toHold.signum() > 0) {
|
||||
holdTxnId = gateway.placeHold(account, toHold, holdReason(acknowledgementNo, layer));
|
||||
held = toHold;
|
||||
}
|
||||
}
|
||||
final BigDecimal gap = disputedAmount.subtract(held).max(BigDecimal.ZERO);
|
||||
// MHA SOP 9.1(i): the hold on future credits is placed only on first-layer accounts (those that received the money
|
||||
// straight from the victim's account), and never on nodal, pool or escrow accounts.
|
||||
final Optional<String> excludedKind = gateway.exclusion(account.type(), account.id());
|
||||
final String noProspective = gap.signum() == 0 || !account.holdable() ? null
|
||||
: layer != 1 ? "not a first-layer account (layer " + layer + ")"
|
||||
: excludedKind.map(k -> k.toLowerCase(java.util.Locale.ROOT) + " account").orElse(null);
|
||||
final BigDecimal shortfall = account.holdable() && noProspective == null ? gap : BigDecimal.ZERO;
|
||||
final String status = account.holdable() ? ACTIVE : NOT_HOLDABLE;
|
||||
final Long holdId = jdbcTemplate.queryForObject(
|
||||
"""
|
||||
INSERT INTO m_i4c_hold (request_id, incident_id, acknowledgement_no, account_type, account_id, account_no, client_id, office_id,
|
||||
layer, disputed_amount, amount_held, shortfall, status, debit_frozen, held_at, review_due_on,
|
||||
intimation_due_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class, requestId, incidentId, acknowledgementNo, account.type(), account.id(), account.accountNo(), account.clientId(),
|
||||
account.officeId(), layer, disputedAmount, held, shortfall, status, false, Timestamp.from(now), Date.valueOf(reviewDue),
|
||||
held.signum() > 0 ? Timestamp.from(intimationDue) : null);
|
||||
if (holdTxnId != null) {
|
||||
insertHoldTxn(holdId, holdTxnId, held, now);
|
||||
}
|
||||
log.info("I4C hold for ack {} layer {} on {} {}: held {} of {}, shortfall {}", acknowledgementNo, layer, account.type(),
|
||||
account.accountNo(), held, disputedAmount, shortfall);
|
||||
if (layer >= 1) {
|
||||
riskService.flagIfRepeat(account);
|
||||
}
|
||||
return new HoldOutcome(holdId, held, shortfall, account.holdable(), account.holdable() ? gap : disputedAmount, noProspective);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hold already placed for the same reported transaction (same complaint, account, layer, RRN and amount) under
|
||||
* an earlier request, so a repeated push does not hold the money twice.
|
||||
*/
|
||||
private Optional<Map<String, Object>> findSameIncident(final Long incidentId, final String ack, final AccountRef account, final int layer) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT h.* FROM m_i4c_hold h
|
||||
JOIN m_i4c_incident prev ON prev.id = h.incident_id
|
||||
JOIN m_i4c_incident cur ON cur.id = ?
|
||||
WHERE h.acknowledgement_no = ? AND h.account_type = ? AND h.account_id = ? AND h.layer = ?
|
||||
AND COALESCE(prev.rrn, '') = COALESCE(cur.rrn, '') AND prev.amount = cur.amount AND prev.id <> cur.id
|
||||
ORDER BY h.id LIMIT 1
|
||||
""", incidentId, ack, account.type(), account.id(), layer);
|
||||
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* The prospective part: holds money that has come into an account since its hold was placed, until the disputed
|
||||
* amount is covered. Each hold is its own transaction, so one account that cannot be held does not stop the rest.
|
||||
*/
|
||||
public int topUpProspectiveHolds() {
|
||||
final List<Map<String, Object>> due = jdbcTemplate
|
||||
.queryForList("SELECT id FROM m_i4c_hold WHERE status = ? AND shortfall > 0 AND layer = 1 ORDER BY held_at LIMIT 500", ACTIVE);
|
||||
int toppedUp = 0;
|
||||
final TransactionTemplate tx = newTransaction();
|
||||
for (final Map<String, Object> row : due) {
|
||||
final Long holdId = ((Number) row.get("id")).longValue();
|
||||
try {
|
||||
final Boolean done = tx.execute(status -> topUp(holdId));
|
||||
if (Boolean.TRUE.equals(done)) {
|
||||
toppedUp++;
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("I4C prospective hold top-up failed for hold {}: {}", holdId, e.getMessage());
|
||||
}
|
||||
}
|
||||
return toppedUp;
|
||||
}
|
||||
|
||||
private boolean topUp(final Long holdId) {
|
||||
final Map<String, Object> h = jdbcTemplate.queryForMap("SELECT * FROM m_i4c_hold WHERE id = ? FOR UPDATE", holdId);
|
||||
final BigDecimal shortfall = (BigDecimal) h.get("shortfall");
|
||||
if (!ACTIVE.equals(h.get("status")) || shortfall.signum() <= 0) {
|
||||
return false;
|
||||
}
|
||||
final Optional<AccountRef> account = gateway.byId((String) h.get("account_type"), ((Number) h.get("account_id")).longValue());
|
||||
if (account.isEmpty() || !account.get().holdable()) {
|
||||
return false;
|
||||
}
|
||||
final BigDecimal available = gateway.withdrawable(account.get());
|
||||
final BigDecimal toHold = available.min(shortfall);
|
||||
if (toHold.signum() <= 0) {
|
||||
return false;
|
||||
}
|
||||
final Instant now = Instant.now();
|
||||
final Long txnId = gateway.placeHold(account.get(), toHold,
|
||||
holdReason((String) h.get("acknowledgement_no"), ((Number) h.get("layer")).intValue()) + " top-up");
|
||||
insertHoldTxn(holdId, txnId, toHold, now);
|
||||
final boolean firstMoney = ((BigDecimal) h.get("amount_held")).signum() == 0;
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold SET amount_held = amount_held + ?, shortfall = shortfall - ?, last_topup_at = ?,
|
||||
intimation_due_by = CASE WHEN ? THEN ? ELSE intimation_due_by END
|
||||
WHERE id = ?
|
||||
""", toHold, toHold, Timestamp.from(now), firstMoney,
|
||||
Timestamp.from(now.plusSeconds(3600L * Math.max(1, properties.getHold().getIntimationHours()))), holdId);
|
||||
log.info("I4C prospective hold {} topped up by {} on {}", holdId, toHold, h.get("account_no"));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Maker: asks for a hold to be released, or for debits on the account to be frozen or unfrozen. */
|
||||
public Long requestAction(final Long holdId, final String action, final String reasonType, final String orderReference,
|
||||
final LocalDate orderDate, final String authority, final String remarks, final AppUser maker) {
|
||||
return requestAction(holdId, action, reasonType, orderReference, orderDate, authority, remarks, null, null, null, maker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker, in full. {@code amount}: for RELEASE, how much to release (null = all, anything less leaves the rest on
|
||||
* hold); for RESTORE, how much goes to the victim. {@code restorationId} (RESTORE) and {@code grievanceId} link the
|
||||
* request to the MRM / GRM record it carries out.
|
||||
*/
|
||||
public Long requestAction(final Long holdId, final String action, final String reasonType, final String orderReference,
|
||||
final LocalDate orderDate, final String authority, final String remarks, final BigDecimal amount, final Long restorationId,
|
||||
final Long grievanceId, final AppUser maker) {
|
||||
final Map<String, Object> hold = loadHold(holdId);
|
||||
final String act = action == null ? "" : action.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!ACTIONS.contains(act)) {
|
||||
throw error("action.invalid", "Action must be one of " + ACTIONS + ".");
|
||||
}
|
||||
final String reason = reasonType == null ? "" : reasonType.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!REASON_TYPES.contains(reason)) {
|
||||
throw error("reason.invalid", "Reason type must be one of " + REASON_TYPES + ".");
|
||||
}
|
||||
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) && 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)) {
|
||||
throw error("amount.not.allowed", "An amount applies only to a release or a restoration.");
|
||||
}
|
||||
if (amount.signum() <= 0 || amount.compareTo(held) > 0) {
|
||||
throw error("amount.invalid", "The amount must be more than zero and at most the amount on hold (" + held.toPlainString() + ").");
|
||||
}
|
||||
}
|
||||
if (ACTION_RESTORE.equals(act)) {
|
||||
if (amount == null || restorationId == null) {
|
||||
throw error("restore.incomplete", "A restoration needs the amount and the MRM restoration record it carries out.");
|
||||
}
|
||||
final Integer linked = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_i4c_restoration WHERE id = ? AND hold_id = ?",
|
||||
Integer.class, restorationId, holdId);
|
||||
if (linked == null || linked == 0) {
|
||||
throw error("restore.record", "The restoration record does not belong to this hold.");
|
||||
}
|
||||
}
|
||||
final Integer pending = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_hold_action_request WHERE hold_id = ? AND status = ?", Integer.class, holdId, PENDING);
|
||||
if (pending != null && pending > 0) {
|
||||
throw error("action.pending", "Another request on this hold is already waiting for approval.");
|
||||
}
|
||||
return jdbcTemplate.queryForObject(
|
||||
"""
|
||||
INSERT INTO m_i4c_hold_action_request (hold_id, action, reason_type, order_reference, order_date, authority, remarks, status,
|
||||
maker_id, maker_name, maker_at, amount, restoration_id, grievance_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class, holdId, act, reason, trim(orderReference, 200), orderDate == null ? null : Date.valueOf(orderDate),
|
||||
trim(authority, 200), trim(remarks, 1000), PENDING, maker.getId(), maker.getDisplayName(), Timestamp.from(Instant.now()),
|
||||
amount, restorationId, grievanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checker: approves and carries out the request, as the checker, in one transaction. If the account refuses (the
|
||||
* hold was already released at the counter, the account was closed), nothing is changed and the request is marked
|
||||
* FAILED with the reason, so it does not sit as pending.
|
||||
*/
|
||||
public void approveAction(final Long requestId, final AppUser checker) {
|
||||
final Map<String, Object> request = loadActionRequest(requestId);
|
||||
requirePending(request);
|
||||
if (checker.getId().equals(((Number) request.get("maker_id")).longValue())) {
|
||||
throw error("same.user", "Maker-checker: you cannot approve a request you raised yourself.");
|
||||
}
|
||||
try {
|
||||
newTransaction().executeWithoutResult(status -> execute(request, checker));
|
||||
} catch (final RuntimeException e) {
|
||||
final String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold_action_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, failure_message = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", FAILED, checker.getId(), checker.getDisplayName(), Timestamp.from(Instant.now()), trim(message, 1000), requestId,
|
||||
PENDING);
|
||||
restorationExecutor.requestEnded(longOrNull(request.get("restoration_id")), "Restoration failed: " + message);
|
||||
throw error("action.failed", "The account refused the change: " + message, e);
|
||||
}
|
||||
if (ACTION_RESTORE.equals(request.get("action"))) {
|
||||
// The money left the suspect account in the approval; an outward transfer is a separate step that may fail
|
||||
// (time window, switch) without putting it back, so it never undoes the approval.
|
||||
restorationExecutor.sendOutwardIfPooled(longOrNull(request.get("restoration_id")));
|
||||
}
|
||||
}
|
||||
|
||||
private void execute(final Map<String, Object> request, final AppUser checker) {
|
||||
final Long requestId = ((Number) request.get("id")).longValue();
|
||||
final Long holdId = ((Number) request.get("hold_id")).longValue();
|
||||
final Map<String, Object> hold = jdbcTemplate.queryForMap("SELECT * FROM m_i4c_hold WHERE id = ? FOR UPDATE", holdId);
|
||||
final AccountRef account = gateway.byId((String) hold.get("account_type"), ((Number) hold.get("account_id")).longValue())
|
||||
.orElseThrow(() -> new IllegalStateException("Account " + hold.get("account_no") + " no longer exists"));
|
||||
final String action = (String) request.get("action");
|
||||
final Instant now = Instant.now();
|
||||
final BigDecimal held = (BigDecimal) hold.get("amount_held");
|
||||
final BigDecimal amount = (BigDecimal) request.get("amount");
|
||||
final boolean partial = ACTION_RELEASE.equals(action) && amount != null && amount.compareTo(held) < 0;
|
||||
switch (action) {
|
||||
case ACTION_RELEASE, ACTION_UNFREEZE_AND_RELEASE -> {
|
||||
if (partial) {
|
||||
// An order names the amount to keep: hold exactly that, and stop topping up beyond it.
|
||||
final BigDecimal keep = held.subtract(amount);
|
||||
rebalance(holdId, hold, account, keep, now);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold SET amount_held = ?, shortfall = 0, released_amount = released_amount + ? WHERE id = ?
|
||||
""", keep, amount, holdId);
|
||||
break;
|
||||
}
|
||||
rebalance(holdId, hold, account, BigDecimal.ZERO, now);
|
||||
if (Boolean.TRUE.equals(hold.get("debit_frozen"))) {
|
||||
gateway.unfreezeDebits(account);
|
||||
}
|
||||
final String reference = request.get("order_reference") == null ? (String) request.get("reason_type")
|
||||
: request.get("reason_type") + " " + request.get("order_reference");
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold SET status = ?, shortfall = 0, debit_frozen = false, released_at = ?, release_reason_type = ?,
|
||||
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
|
||||
// for the account holder in between.
|
||||
final BigDecimal keep = held.subtract(amount);
|
||||
rebalance(holdId, hold, account, keep, now);
|
||||
restorationExecutor.carryOut(longOrNull(request.get("restoration_id")), account, amount, (String) hold.get("acknowledgement_no"));
|
||||
final boolean emptied = keep.signum() == 0;
|
||||
if (emptied && Boolean.TRUE.equals(hold.get("debit_frozen"))) {
|
||||
gateway.unfreezeDebits(account);
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold SET amount_held = ?, shortfall = 0, restored_amount = restored_amount + ?,
|
||||
status = CASE WHEN ? THEN ? ELSE status END,
|
||||
debit_frozen = CASE WHEN ? THEN false ELSE debit_frozen END,
|
||||
released_at = CASE WHEN ? THEN ? ELSE released_at END,
|
||||
release_reason_type = CASE WHEN ? THEN 'MRM_RESTORATION' ELSE release_reason_type END,
|
||||
release_reference = CASE WHEN ? THEN ? ELSE release_reference END
|
||||
WHERE id = ?
|
||||
""", keep, amount, emptied, RELEASED, emptied, emptied, Timestamp.from(now), emptied, emptied,
|
||||
trim("MRM_RESTORATION " + (request.get("order_reference") == null ? "" : request.get("order_reference")), 200),
|
||||
holdId);
|
||||
}
|
||||
case ACTION_FREEZE -> {
|
||||
gateway.freezeDebits(account, "I4C CFCFRMS Ack " + hold.get("acknowledgement_no") + " debit freeze");
|
||||
jdbcTemplate.update("UPDATE m_i4c_hold SET debit_frozen = true WHERE id = ?", holdId);
|
||||
}
|
||||
case ACTION_UNFREEZE -> {
|
||||
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("""
|
||||
UPDATE m_i4c_hold_action_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", APPROVED, checker.getId(), checker.getDisplayName(), Timestamp.from(now), requestId, PENDING);
|
||||
if (updated == 0) {
|
||||
throw new IllegalStateException("This request was already actioned by someone else.");
|
||||
}
|
||||
final Long grievanceId = longOrNull(request.get("grievance_id"));
|
||||
if (grievanceId != null) {
|
||||
// The GRM order is now carried out on the account.
|
||||
jdbcTemplate.update("UPDATE m_i4c_grievance SET stage = 'CLOSED', compliance_due_at = NULL, due_at = NULL, updated_at = ? WHERE id = ?",
|
||||
Timestamp.from(now), grievanceId);
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_grievance_event (grievance_id, event_type, stage, remarks, actor, created_at) VALUES (?,?,?,?,?,?)
|
||||
""", grievanceId, "CARRIED_OUT", "CLOSED", action + " approved on the account (request " + requestId + ")",
|
||||
trim(checker.getDisplayName(), 100), Timestamp.from(now));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves exactly {@code keep} on hold for this register row: releases every hold transaction still on, then places
|
||||
* one new hold for {@code keep} (none when zero). Runs in the caller's transaction.
|
||||
*/
|
||||
private void rebalance(final Long holdId, final Map<String, Object> hold, final AccountRef account, final BigDecimal keep,
|
||||
final Instant now) {
|
||||
final List<Map<String, Object>> txns = jdbcTemplate
|
||||
.queryForList("SELECT id, account_txn_id FROM m_i4c_hold_txn WHERE hold_id = ? AND released_at IS NULL", holdId);
|
||||
for (final Map<String, Object> t : txns) {
|
||||
final Long accountTxnId = ((Number) t.get("account_txn_id")).longValue();
|
||||
if (gateway.isHoldStillOn(account, accountTxnId)) {
|
||||
gateway.releaseHold(account, accountTxnId);
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_hold_txn SET released_at = ? WHERE id = ?", Timestamp.from(now), t.get("id"));
|
||||
}
|
||||
if (keep.signum() > 0) {
|
||||
final Long txnId = gateway.placeHold(account, keep,
|
||||
holdReason((String) hold.get("acknowledgement_no"), ((Number) hold.get("layer")).intValue()) + " per order");
|
||||
insertHoldTxn(holdId, txnId, keep, now);
|
||||
}
|
||||
}
|
||||
|
||||
/** Digital channels stay suspended while any other active hold remains on the account. */
|
||||
private void liftDigitalIfLast(final Map<String, Object> hold) {
|
||||
if (!Boolean.TRUE.equals(hold.get("digital_suspended"))) {
|
||||
return;
|
||||
}
|
||||
final Integer others = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_hold WHERE account_type = ? AND account_id = ? AND status = ? AND id <> ?", Integer.class,
|
||||
hold.get("account_type"), hold.get("account_id"), ACTIVE, hold.get("id"));
|
||||
if (others == null || others == 0) {
|
||||
riskService.restoreDigital(hold, "system (hold released)");
|
||||
}
|
||||
}
|
||||
|
||||
private static Long longOrNull(final Object value) {
|
||||
return value == null ? null : ((Number) value).longValue();
|
||||
}
|
||||
|
||||
public void rejectAction(final Long requestId, final String reason, final AppUser checker) {
|
||||
final Map<String, Object> request = loadActionRequest(requestId);
|
||||
requirePending(request);
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw error("reject.reason", "A reason is required to reject.");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold_action_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", REJECTED, checker.getId(), checker.getDisplayName(), Timestamp.from(Instant.now()), trim(reason, 1000), requestId,
|
||||
PENDING);
|
||||
restorationExecutor.requestEnded(longOrNull(request.get("restoration_id")), "Restoration rejected by checker: " + reason);
|
||||
}
|
||||
|
||||
/** Records that the customer was told about the hold (RBI draft directions: by the end of the next day). */
|
||||
public void markIntimated(final Long holdId, final String mode, final String reference, final AppUser user) {
|
||||
loadHold(holdId);
|
||||
if (mode == null || mode.isBlank()) {
|
||||
throw error("intimation.mode", "Say how the customer was told (SMS, letter, email, in person).");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold SET customer_intimated_at = ?, intimation_mode = ?, intimation_reference = ?, intimated_by = ?
|
||||
WHERE id = ?
|
||||
""", Timestamp.from(Instant.now()), trim(mode, 30), trim(reference, 200), trim(user.getDisplayName(), 100), holdId);
|
||||
}
|
||||
|
||||
public Map<String, Object> loadHold(final Long holdId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", holdId);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("hold.not.found", "I4C hold " + holdId + " not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private Optional<Map<String, Object>> findByIncident(final Long incidentId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE incident_id = ?", incidentId);
|
||||
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
|
||||
}
|
||||
|
||||
private Map<String, Object> loadActionRequest(final Long requestId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold_action_request WHERE id = ?", requestId);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("action.not.found", "Hold action request " + requestId + " not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private static void requirePending(final Map<String, Object> request) {
|
||||
if (!PENDING.equals(request.get("status"))) {
|
||||
throw error("action.status", "Only PENDING requests can be actioned. Current status: " + request.get("status"));
|
||||
}
|
||||
}
|
||||
|
||||
private void insertHoldTxn(final Long holdId, final Long accountTxnId, final BigDecimal amount, final Instant at) {
|
||||
jdbcTemplate.update("INSERT INTO m_i4c_hold_txn (hold_id, account_txn_id, amount, placed_at) VALUES (?,?,?,?)", holdId,
|
||||
accountTxnId, amount, Timestamp.from(at));
|
||||
}
|
||||
|
||||
private TransactionTemplate newTransaction() {
|
||||
final TransactionTemplate tx = new TransactionTemplate(transactionManager);
|
||||
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
return tx;
|
||||
}
|
||||
|
||||
static String holdReason(final String acknowledgementNo, final int layer) {
|
||||
return "I4C CFCFRMS Ack " + acknowledgementNo + " L" + layer;
|
||||
}
|
||||
|
||||
private static PlatformDataIntegrityException error(final String code, final String message, final Object... cause) {
|
||||
return new PlatformDataIntegrityException("error.msg.i4c." + code, message, cause);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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 com.google.gson.JsonObject;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.crypto.I4cCryptoJsAes;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Authenticates I4C when it calls the bank (specification section 2: "Authenticate from I4C-MHA to BANKS").
|
||||
*
|
||||
* I4C posts the username and password the bank issued to it and receives an access token; every complaint push then
|
||||
* carries that token as a Bearer credential. The token is 32 random bytes; only its SHA-256 hash is stored, so a copy
|
||||
* of the table is useless to anyone reading it. Each check also requires the caller's IP to be on the allowlist I4C
|
||||
* publishes ("Public IPs will be shared separately"), so a leaked token alone is not enough.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cInboundAuthService {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final BCryptPasswordEncoder BCRYPT = new BCryptPasswordEncoder();
|
||||
|
||||
private final I4cProperties properties;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public record IssuedToken(String accessToken, long expiresInSeconds) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the posted credentials, which may arrive encrypted (CryptoJS format, when the crypto mode is FIELDS or
|
||||
* ENVELOPE). Empty when they do not match or the integration has no inbound credentials configured.
|
||||
*/
|
||||
public Optional<IssuedToken> issueToken(final String username, final String password, final String clientIp) {
|
||||
final I4cProperties.Inbound inbound = properties.getInbound();
|
||||
if (isBlank(inbound.getUsername()) || (isBlank(inbound.getPasswordHash()) && isBlank(inbound.getPassword()))) {
|
||||
log.warn("I4C token request refused: fineract.i4c.inbound credentials are not configured");
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!credentialsMatch(username, password)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
final byte[] raw = new byte[32];
|
||||
RANDOM.nextBytes(raw);
|
||||
final String token = Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
|
||||
final Instant now = Instant.now();
|
||||
final long ttl = Math.max(60, inbound.getTokenTtlSeconds());
|
||||
jdbcTemplate.update("INSERT INTO m_i4c_access_token (token_hash, client_ip, issued_at, expires_at, revoked) VALUES (?,?,?,?,?)",
|
||||
sha256(token), clientIp, Timestamp.from(now), Timestamp.from(now.plusSeconds(ttl)), false);
|
||||
return Optional.of(new IssuedToken(token, ttl));
|
||||
}
|
||||
|
||||
/** A Bearer token issued by {@link #issueToken} that has not expired or been revoked. */
|
||||
public boolean isValidToken(final String authorizationHeader) {
|
||||
if (authorizationHeader == null || !authorizationHeader.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
return false;
|
||||
}
|
||||
final String token = authorizationHeader.substring(7).trim();
|
||||
if (token.isEmpty() || token.length() > 200) {
|
||||
return false;
|
||||
}
|
||||
final Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_access_token WHERE token_hash = ? AND revoked = false AND expires_at > ?", Integer.class,
|
||||
sha256(token), Timestamp.from(Instant.now()));
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
/** Which of the specification's authentication forms a complaint push used; null when none was valid. */
|
||||
public I4cProperties.AuthMode authenticatePush(final String authorizationHeader, final JsonObject body) {
|
||||
final I4cProperties.Inbound inbound = properties.getInbound();
|
||||
if (inbound.allows(I4cProperties.AuthMode.TOKEN) && isValidToken(authorizationHeader)) {
|
||||
return I4cProperties.AuthMode.TOKEN;
|
||||
}
|
||||
if (inbound.allows(I4cProperties.AuthMode.BASIC) && isValidBasic(authorizationHeader)) {
|
||||
return I4cProperties.AuthMode.BASIC;
|
||||
}
|
||||
if (inbound.allows(I4cProperties.AuthMode.BODY) && body != null) {
|
||||
final String[] credentials = credentialsIn(body);
|
||||
if (credentials != null && credentialsMatch(credentials[0], credentials[1])) {
|
||||
return I4cProperties.AuthMode.BODY;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization: Basic base64(user:password) - specification section 2, "Basic Auth / Auth 2.0". The user and password
|
||||
* inside may themselves be CryptoJS-encrypted.
|
||||
*/
|
||||
public boolean isValidBasic(final String authorizationHeader) {
|
||||
final String[] credentials = basicCredentials(authorizationHeader);
|
||||
return credentials != null && credentialsMatch(credentials[0], credentials[1]);
|
||||
}
|
||||
|
||||
/** user and password from an Authorization: Basic header, or null when it is not one. */
|
||||
public static String[] basicCredentials(final String authorizationHeader) {
|
||||
if (authorizationHeader == null || !authorizationHeader.regionMatches(true, 0, "Basic ", 0, 6)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final String decoded = new String(Base64.getDecoder().decode(authorizationHeader.substring(6).trim()), StandardCharsets.UTF_8);
|
||||
final int colon = decoded.indexOf(':');
|
||||
return colon < 0 ? null : new String[] { decoded.substring(0, colon), decoded.substring(colon + 1) };
|
||||
} catch (final IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The credentials a message carries in its own JSON (section 2: "the bank will extract the user ID and password from
|
||||
* the request JSON message"), under username/password or the OAuth names client_id/client_secret. They are removed
|
||||
* from the message once read, so they are never stored with it.
|
||||
*/
|
||||
public static String[] credentialsIn(final JsonObject body) {
|
||||
final String user = firstText(body, "username", "user_id", "userid", "client_id", "clientId");
|
||||
final String pass = firstText(body, "password", "client_secret", "clientSecret");
|
||||
for (final String key : new String[] { "username", "user_id", "userid", "client_id", "clientId", "password", "client_secret",
|
||||
"clientSecret" }) {
|
||||
body.remove(key);
|
||||
}
|
||||
return user == null || pass == null ? null : new String[] { user, pass };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user and password are the ones issued to I4C. Each may arrive plain, CryptoJS-encrypted (the
|
||||
* specification's samples), or plain Base64 (its text says "Base64-encoded"); every form is tried, in constant time.
|
||||
*/
|
||||
public boolean credentialsMatch(final String username, final String password) {
|
||||
final I4cProperties.Inbound inbound = properties.getInbound();
|
||||
if (isBlank(inbound.getUsername()) || (isBlank(inbound.getPasswordHash()) && isBlank(inbound.getPassword()))) {
|
||||
return false;
|
||||
}
|
||||
boolean userOk = false;
|
||||
for (final String candidate : forms(username)) {
|
||||
userOk |= MessageDigest.isEqual(candidate.getBytes(StandardCharsets.UTF_8), inbound.getUsername().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
if (!userOk) {
|
||||
return false;
|
||||
}
|
||||
for (final String candidate : forms(password)) {
|
||||
if (passwordMatches(candidate, inbound)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The value as sent, decrypted when it is in the Salted__ format, and Base64-decoded when it is printable Base64. */
|
||||
private List<String> forms(final String value) {
|
||||
if (value == null) {
|
||||
return List.of();
|
||||
}
|
||||
final List<String> out = new java.util.ArrayList<>();
|
||||
final String decrypted = decryptIfNeeded(value);
|
||||
if (decrypted != null) {
|
||||
out.add(decrypted);
|
||||
}
|
||||
if (!I4cCryptoJsAes.looksEncrypted(value) && value.length() >= 4 && value.length() % 4 == 0 && value.matches("[A-Za-z0-9+/]+={0,2}")) {
|
||||
try {
|
||||
final String decoded = new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8);
|
||||
if (!decoded.isEmpty() && decoded.chars().allMatch(c -> c >= 0x20 && c != 0x7f && c != 0xfffd)) {
|
||||
out.add(decoded);
|
||||
}
|
||||
} catch (final IllegalArgumentException ignored) {
|
||||
// not Base64 after all
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String firstText(final JsonObject body, final String... keys) {
|
||||
for (final String key : keys) {
|
||||
if (body.has(key) && body.get(key).isJsonPrimitive()) {
|
||||
final String value = body.get(key).getAsString();
|
||||
if (!value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isIpAllowed(final String clientIp) {
|
||||
final List<String> allowed = properties.getInbound().allowedIpList();
|
||||
return allowed.isEmpty() || (clientIp != null && allowed.contains(clientIp));
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's address. X-Forwarded-For is honoured only when configured, because a client can set that header
|
||||
* itself; behind a reverse proxy the proxy's own address would otherwise be the only one seen.
|
||||
*/
|
||||
public String clientIp(final HttpServletRequest request) {
|
||||
if (properties.getInbound().isTrustForwardedFor()) {
|
||||
final String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/** Tokens expired for more than a day are of no further use, even for audit (the API log keeps the calls). */
|
||||
public int purgeExpiredTokens() {
|
||||
return jdbcTemplate.update("DELETE FROM m_i4c_access_token WHERE expires_at < ?",
|
||||
Timestamp.from(Instant.now().minusSeconds(86_400)));
|
||||
}
|
||||
|
||||
private String decryptIfNeeded(final String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final I4cProperties.Crypto crypto = properties.getCrypto();
|
||||
if (crypto.getMode() != I4cProperties.CryptoMode.NONE && I4cCryptoJsAes.looksEncrypted(value)) {
|
||||
if (!crypto.hasPassphrase()) {
|
||||
log.warn("I4C sent encrypted credentials but fineract.i4c.crypto.passphrase is not set");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return I4cCryptoJsAes.decrypt(value, crypto.getPassphrase());
|
||||
} catch (final I4cCryptoJsAes.I4cCryptoException e) {
|
||||
log.warn("I4C credentials could not be decrypted: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean passwordMatches(final String plainPassword, final I4cProperties.Inbound inbound) {
|
||||
if (!isBlank(inbound.getPasswordHash())) {
|
||||
final String hash = inbound.getPasswordHash().startsWith("{bcrypt}") ? inbound.getPasswordHash().substring(8)
|
||||
: inbound.getPasswordHash();
|
||||
try {
|
||||
return BCRYPT.matches(plainPassword, hash);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
log.error("fineract.i4c.inbound.password-hash is not a bcrypt hash");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return MessageDigest.isEqual(plainPassword.getBytes(StandardCharsets.UTF_8),
|
||||
inbound.getPassword().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
static String sha256(final String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(final String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 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.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
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.infrastructure.email.service.EmailSenderService;
|
||||
import org.apache.fineract.portfolio.client.domain.Client;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
|
||||
import org.apache.fineract.portfolio.client.notification.CustomerNotificationDispatcher;
|
||||
import org.apache.fineract.portfolio.client.notification.CustomerNotificationResult;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Tells the account holder about an I4C hold (RBI draft directions: by the end of the next day, with the reason, how to
|
||||
* have it removed and whom to contact).
|
||||
*
|
||||
* The channels that send on their own - EMAIL, SMS, WHATSAPP - are switched on, ordered and worded on the I4C Live >
|
||||
* Intimation Settings screen ({@code m_i4c_intimation_channel}). When money is first held, the switched-on channels are
|
||||
* tried in priority order through the CBS's existing customer-notification gateways ({@link CustomerNotificationDispatcher}:
|
||||
* SMTP mail, the SMS and WhatsApp REST gateways); the first that reaches the customer records the intimation on the hold.
|
||||
* If none is switched on, or none reaches the customer (no email on file, gateway down), the hold stays "customer not
|
||||
* yet told" for an officer to phone or write and record by hand. Every attempt, automatic or manual, is logged.
|
||||
*
|
||||
* A failed automatic notice is retried by the I4C Hold Review job, up to {@link #MAX_AUTO_ATTEMPTS} times.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cIntimationService {
|
||||
|
||||
/** Channels this CBS can transmit itself. PHONE, LETTER and IN_PERSON are always recorded by an officer. */
|
||||
public static final Set<String> AUTOMATIC_CHANNELS = Set.of("EMAIL", "SMS", "WHATSAPP");
|
||||
public static final Set<String> MANUAL_CHANNELS = Set.of("PHONE", "LETTER", "IN_PERSON", "SMS", "EMAIL", "WHATSAPP");
|
||||
static final int MAX_AUTO_ATTEMPTS = 3;
|
||||
private static final String SYSTEM_ACTOR = "System (automatic)";
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final CustomerNotificationDispatcher dispatcher;
|
||||
private final ClientRepositoryWrapper clientRepository;
|
||||
private final EmailSenderService emailSender;
|
||||
private final I4cBankDirectory banks;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
public enum Outcome {
|
||||
SENT, NO_CHANNEL_ENABLED, NOT_DELIVERED, ALREADY_INTIMATED, NOTHING_HELD
|
||||
}
|
||||
|
||||
public record Result(Outcome outcome, String channel, String destination, String message) {}
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────────
|
||||
|
||||
public List<Map<String, Object>> channels() {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT channel, enabled, priority, subject, body, updated_by, updated_at FROM m_i4c_intimation_channel ORDER BY priority, channel");
|
||||
}
|
||||
|
||||
public List<String> enabledChannels() {
|
||||
return jdbcTemplate.queryForList("SELECT channel FROM m_i4c_intimation_channel WHERE enabled = true ORDER BY priority, channel",
|
||||
String.class);
|
||||
}
|
||||
|
||||
public void updateChannel(final String channel, final Boolean enabled, final Integer priority, final String subject, final String body,
|
||||
final String actor) {
|
||||
final String ch = normalise(channel);
|
||||
if (!AUTOMATIC_CHANNELS.contains(ch)) {
|
||||
throw error("channel.invalid", "Only EMAIL, SMS and WHATSAPP can send automatically; PHONE, LETTER and IN_PERSON are"
|
||||
+ " always recorded by an officer.");
|
||||
}
|
||||
if (body != null && body.isBlank()) {
|
||||
throw error("body.required", "The message text cannot be empty.");
|
||||
}
|
||||
if ("EMAIL".equals(ch) && subject != null && subject.isBlank()) {
|
||||
throw error("subject.required", "An email needs a subject.");
|
||||
}
|
||||
if (priority != null && (priority < 1 || priority > 99)) {
|
||||
throw error("priority.invalid", "Priority must be between 1 and 99.");
|
||||
}
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_i4c_intimation_channel
|
||||
SET enabled = COALESCE(?, enabled), priority = COALESCE(?, priority), subject = COALESCE(?, subject),
|
||||
body = COALESCE(?, body), updated_by = ?, updated_at = ?
|
||||
WHERE channel = ?
|
||||
""", enabled, priority, subject == null ? null : trim(subject, 300), body, trim(actor, 100), Timestamp.from(Instant.now()),
|
||||
ch);
|
||||
if (updated == 0) {
|
||||
throw error("channel.not.found", "Channel " + ch + " is not configured.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the channel's message, filled with sample values, to an address of the officer's choosing. EMAIL only. */
|
||||
public Result sendTest(final String channel, final String to) {
|
||||
final String ch = normalise(channel);
|
||||
if (!"EMAIL".equals(ch)) {
|
||||
throw error("test.email.only", "A test message can be sent for EMAIL only.");
|
||||
}
|
||||
if (to == null || !to.matches("[^@\\s]+@[^@\\s]+\\.[^@\\s]+")) {
|
||||
throw error("test.address", "Give a valid email address to send the test to.");
|
||||
}
|
||||
final Map<String, Object> settings = settings(ch);
|
||||
final Map<String, String> sample = sampleValues();
|
||||
final boolean accepted = emailSender.sendText(to.trim(), "[TEST] " + render((String) settings.get("subject"), sample),
|
||||
render((String) settings.get("body"), sample));
|
||||
return accepted ? new Result(Outcome.SENT, ch, to, "Test email accepted by the mail server")
|
||||
: new Result(Outcome.NOT_DELIVERED, ch, to, "The mail server did not accept the message; check spring.mail settings");
|
||||
}
|
||||
|
||||
// ── Sending ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every hold of one complaint that now has money held and no intimation yet. Called after a complaint is processed. */
|
||||
public void intimateForRequest(final Long requestId) {
|
||||
for (final Long holdId : jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_i4c_hold WHERE request_id = ? AND status = 'ACTIVE' AND amount_held > 0 AND customer_intimated_at IS NULL
|
||||
""", Long.class, requestId)) {
|
||||
safely(holdId, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** For the I4C Hold Review job: holds that are still untold (first money arrived by top-up, or an earlier attempt failed). */
|
||||
public int retryPending() {
|
||||
int sent = 0;
|
||||
for (final Long holdId : jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_i4c_hold
|
||||
WHERE status = 'ACTIVE' AND amount_held > 0 AND customer_intimated_at IS NULL AND auto_intimation_attempts < ?
|
||||
ORDER BY held_at LIMIT 200
|
||||
""", Long.class, MAX_AUTO_ATTEMPTS)) {
|
||||
if (safely(holdId, false).outcome() == Outcome.SENT) {
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** An officer's "Send now": tries the switched-on channels again, whatever the earlier attempts. */
|
||||
public Result sendNow(final Long holdId, final String actor) {
|
||||
return intimate(holdId, true, actor);
|
||||
}
|
||||
|
||||
private Result safely(final Long holdId, final boolean resend) {
|
||||
try {
|
||||
return intimate(holdId, resend, SYSTEM_ACTOR);
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("I4C automatic intimation for hold {} failed: {}", holdId, e.getMessage());
|
||||
return new Result(Outcome.NOT_DELIVERED, null, null, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Result intimate(final Long holdId, final boolean resend, final String actor) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", holdId);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("hold.not.found", "I4C hold " + holdId + " not found.");
|
||||
}
|
||||
final Map<String, Object> hold = rows.get(0);
|
||||
if (hold.get("customer_intimated_at") != null) {
|
||||
return new Result(Outcome.ALREADY_INTIMATED, (String) hold.get("intimation_mode"), null, "The customer has already been told.");
|
||||
}
|
||||
final BigDecimal held = (BigDecimal) hold.get("amount_held");
|
||||
if (held == null || held.signum() <= 0 || !I4cHoldService.ACTIVE.equals(hold.get("status"))) {
|
||||
return new Result(Outcome.NOTHING_HELD, null, null, "Nothing is held yet, so there is nothing to tell the customer.");
|
||||
}
|
||||
final List<String> channels = enabledChannels();
|
||||
if (channels.isEmpty()) {
|
||||
return new Result(Outcome.NO_CHANNEL_ENABLED, null, null,
|
||||
"No automatic channel is switched on: contact the customer and record it by hand.");
|
||||
}
|
||||
final Long clientId = hold.get("client_id") instanceof Number n ? n.longValue() : null;
|
||||
if (clientId == null) {
|
||||
return new Result(Outcome.NOT_DELIVERED, null, null, "The account has no customer on file.");
|
||||
}
|
||||
|
||||
final Map<String, String> values = values(hold, clientId);
|
||||
String lastReason = null;
|
||||
for (final String channel : channels) {
|
||||
final Map<String, Object> settings = settings(channel);
|
||||
final String subject = render((String) settings.get("subject"), values);
|
||||
final String body = render((String) settings.get("body"), values);
|
||||
final CustomerNotificationResult result = dispatch(channel, clientId, subject, body);
|
||||
log(holdId, channel, resend ? "RESEND" : "AUTO", result.status().name(), result.destinationMasked(), result.provider(),
|
||||
result.providerReference(), result.failureReason(), actor);
|
||||
if (result.isDelivered()) {
|
||||
final String reference = "Sent automatically to " + (result.destinationMasked() == null ? "customer" : result.destinationMasked())
|
||||
+ (result.providerReference() == null ? "" : " (ref " + result.providerReference() + ")");
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold SET customer_intimated_at = ?, intimation_mode = ?, intimation_reference = ?, intimated_by = ?,
|
||||
auto_intimation_attempts = auto_intimation_attempts + 1
|
||||
WHERE id = ? AND customer_intimated_at IS NULL
|
||||
""", Timestamp.from(Instant.now()), channel, trim(reference, 200), trim(actor, 100), holdId);
|
||||
return new Result(Outcome.SENT, channel, result.destinationMasked(), reference);
|
||||
}
|
||||
lastReason = channel + ": " + (result.failureReason() == null ? result.status().name() : result.failureReason());
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_hold SET auto_intimation_attempts = auto_intimation_attempts + 1 WHERE id = ?", holdId);
|
||||
return new Result(Outcome.NOT_DELIVERED, null, null,
|
||||
"Could not reach the customer automatically (" + lastReason + "): contact them and record it by hand.");
|
||||
}
|
||||
|
||||
/** Loads the customer inside a read-only transaction and hands it to the CBS gateway for the channel. */
|
||||
private CustomerNotificationResult dispatch(final String channel, final Long clientId, final String subject, final String body) {
|
||||
final TransactionTemplate tx = new TransactionTemplate(transactionManager);
|
||||
tx.setReadOnly(true);
|
||||
return tx.execute(status -> {
|
||||
final Client client = clientRepository.findOneWithNotFoundDetection(clientId);
|
||||
return dispatcher.dispatch(KycContactChannel.valueOf(channel), client, subject, body, null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notice to a customer on the switched-on automatic channels, in order, until one reaches them; for notices
|
||||
* that are not about an I4C hold (a bank-initiated debit hold). Returns the result of the channel that delivered, or
|
||||
* NOT_DELIVERED / NO_CHANNEL_ENABLED.
|
||||
*/
|
||||
public Result notifyCustomer(final Long clientId, final String subject, final String body) {
|
||||
final List<String> channels = enabledChannels();
|
||||
if (channels.isEmpty() || clientId == null) {
|
||||
return new Result(Outcome.NO_CHANNEL_ENABLED, null, null, "No automatic channel is switched on; tell the customer by hand.");
|
||||
}
|
||||
String lastReason = "";
|
||||
for (final String channel : channels) {
|
||||
try {
|
||||
final CustomerNotificationResult result = dispatch(channel, clientId, subject, body);
|
||||
if (result.isDelivered()) {
|
||||
return new Result(Outcome.SENT, channel, result.destinationMasked(), "Sent to " + result.destinationMasked());
|
||||
}
|
||||
lastReason = channel + ": " + (result.failureReason() == null ? result.status().name() : result.failureReason());
|
||||
} catch (final RuntimeException e) {
|
||||
lastReason = channel + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
return new Result(Outcome.NOT_DELIVERED, null, null, "Could not reach the customer automatically (" + lastReason + ").");
|
||||
}
|
||||
|
||||
/** An officer's own record (phone call, letter, visit), kept in the same log as the automatic notices. */
|
||||
public void logManual(final Long holdId, final String channel, final String reference, final String actor) {
|
||||
log(holdId, normalise(channel), "MANUAL", "RECORDED", null, null, trim(reference, 200), null, actor);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> logFor(final Long holdId) {
|
||||
return jdbcTemplate.queryForList("SELECT * FROM m_i4c_intimation_log WHERE hold_id = ? ORDER BY id DESC", holdId);
|
||||
}
|
||||
|
||||
private void log(final Long holdId, final String channel, final String mode, final String status, final String destination,
|
||||
final String provider, final String reference, final String reason, final String actor) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_intimation_log (hold_id, channel, mode, status, destination_masked, provider, provider_reference,
|
||||
failure_reason, actor, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
""", holdId, channel, mode, status, trim(destination, 120), trim(provider, 30), trim(reference, 200), trim(reason, 1000),
|
||||
trim(actor, 100), Timestamp.from(Instant.now()));
|
||||
}
|
||||
|
||||
// ── Message text ────────────────────────────────────────────────────────
|
||||
|
||||
/** The placeholders a template may use, as shown on the settings screen. */
|
||||
public static final List<String> PLACEHOLDERS = List.of("{customerName}", "{accountNumber}", "{amountHeld}", "{disputedAmount}",
|
||||
"{ackNo}", "{holdDate}", "{reviewDate}", "{bankName}", "{branchName}", "{grievanceEmail}", "{customerCare}");
|
||||
|
||||
private Map<String, String> values(final Map<String, Object> hold, final Long clientId) {
|
||||
final Map<String, String> v = new LinkedHashMap<>();
|
||||
final List<String> names = jdbcTemplate.queryForList("SELECT display_name FROM m_client WHERE id = ?", String.class, clientId);
|
||||
v.put("customerName", names.isEmpty() || names.get(0) == null ? "Customer" : names.get(0));
|
||||
v.put("accountNumber", maskAccount((String) hold.get("account_no")));
|
||||
v.put("amountHeld", I4cResponseBuilder.money((BigDecimal) hold.get("amount_held")));
|
||||
v.put("disputedAmount", I4cResponseBuilder.money((BigDecimal) hold.get("disputed_amount")));
|
||||
v.put("ackNo", String.valueOf(hold.get("acknowledgement_no")));
|
||||
v.put("holdDate", hold.get("held_at") instanceof Timestamp ts ? ts.toLocalDateTime().toLocalDate().format(DATE) : "");
|
||||
v.put("reviewDate", hold.get("review_due_on") == null ? "" : I4cTransactionTracer.toLocalDate(hold.get("review_due_on")).format(DATE));
|
||||
final Long officeId = hold.get("office_id") instanceof Number n ? n.longValue() : null;
|
||||
final List<String> branch = officeId == null ? List.of()
|
||||
: jdbcTemplate.queryForList("SELECT name FROM m_office WHERE id = ?", String.class, officeId);
|
||||
v.put("branchName", branch.isEmpty() || branch.get(0) == null ? "" : branch.get(0));
|
||||
institution(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
private void institution(final Map<String, String> v) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT grievance_email, customer_care_number, contact_email FROM m_institution_profile WHERE id = 1");
|
||||
final Map<String, Object> p = rows.isEmpty() ? Map.of() : rows.get(0);
|
||||
v.put("bankName", banks.ownBank().name());
|
||||
v.put("grievanceEmail", firstNonBlank((String) p.get("grievance_email"), (String) p.get("contact_email")));
|
||||
v.put("customerCare", firstNonBlank((String) p.get("customer_care_number")));
|
||||
}
|
||||
|
||||
private Map<String, String> sampleValues() {
|
||||
final Map<String, String> v = new LinkedHashMap<>();
|
||||
v.put("customerName", "Sample Customer");
|
||||
v.put("accountNumber", "XXXXXXXX0006");
|
||||
v.put("amountHeld", "1500.00");
|
||||
v.put("disputedAmount", "1500.00");
|
||||
v.put("ackNo", "31608230000001");
|
||||
v.put("holdDate", DateUtils.getBusinessLocalDate().format(DATE));
|
||||
v.put("reviewDate", DateUtils.getBusinessLocalDate().plusDays(90).format(DATE));
|
||||
v.put("branchName", "Head Office");
|
||||
institution(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
static String render(final String template, final Map<String, String> values) {
|
||||
if (template == null) {
|
||||
return "";
|
||||
}
|
||||
String out = template;
|
||||
for (final Map.Entry<String, String> e : values.entrySet()) {
|
||||
out = out.replace("{" + e.getKey() + "}", e.getValue() == null ? "" : e.getValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The account number with all but its last four characters hidden, as notices should show it. */
|
||||
static String maskAccount(final String accountNo) {
|
||||
if (accountNo == null || accountNo.length() <= 4) {
|
||||
return accountNo == null ? "" : accountNo;
|
||||
}
|
||||
return "X".repeat(accountNo.length() - 4) + accountNo.substring(accountNo.length() - 4);
|
||||
}
|
||||
|
||||
private Map<String, Object> settings(final String channel) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_intimation_channel WHERE channel = ?", channel);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("channel.not.found", "Channel " + channel + " is not configured.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private static String normalise(final String channel) {
|
||||
return channel == null ? "" : channel.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String firstNonBlank(final String... values) {
|
||||
for (final String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static PlatformDataIntegrityException error(final String code, final String message) {
|
||||
return new PlatformDataIntegrityException("error.msg.i4c.intimation." + code, message);
|
||||
}
|
||||
|
||||
private static String trim(final String value, final int max) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
|
||||
/**
|
||||
* The filters, date range and page of an officer list screen, read from the query string.
|
||||
*
|
||||
* {@code fromDate} / {@code toDate} are {@code yyyy-MM-dd} and both inclusive; {@code offset} and {@code limit} pick the
|
||||
* page. Every other key is a screen-specific filter read with {@link #text} or {@link #upper}.
|
||||
*/
|
||||
public final class I4cListQuery {
|
||||
|
||||
public static final int MAX_LIMIT = 500;
|
||||
|
||||
private final Map<String, List<String>> params;
|
||||
private final LocalDate fromDate;
|
||||
private final LocalDate toDate;
|
||||
private final int offset;
|
||||
private final int limit;
|
||||
|
||||
private I4cListQuery(final Map<String, List<String>> params, final int defaultLimit) {
|
||||
this.params = params == null ? Map.of() : params;
|
||||
this.fromDate = date("fromDate");
|
||||
this.toDate = date("toDate");
|
||||
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.date.range.invalid", "The To date cannot be before the From date.");
|
||||
}
|
||||
this.offset = Math.max(number("offset", 0), 0);
|
||||
final int requested = number("limit", defaultLimit);
|
||||
this.limit = requested <= 0 ? defaultLimit : Math.min(requested, MAX_LIMIT);
|
||||
}
|
||||
|
||||
public static I4cListQuery of(final Map<String, List<String>> params, final int defaultLimit) {
|
||||
return new I4cListQuery(params, defaultLimit);
|
||||
}
|
||||
|
||||
/** A filter value, trimmed; {@code null} when absent or blank. */
|
||||
public String text(final String key) {
|
||||
final List<String> values = params.get(key);
|
||||
if (values == null || values.isEmpty() || values.get(0) == null || values.get(0).isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return values.get(0).trim();
|
||||
}
|
||||
|
||||
public String upper(final String key) {
|
||||
final String value = text(key);
|
||||
return value == null ? null : value.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public boolean flag(final String key) {
|
||||
return "true".equalsIgnoreCase(text(key));
|
||||
}
|
||||
|
||||
public Integer integer(final String key) {
|
||||
final String value = text(key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value);
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.filter.invalid", "Filter " + key + " must be a number.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Start of the From day, for {@code column >= ?}. */
|
||||
public Timestamp fromStart() {
|
||||
return fromDate == null ? null : Timestamp.valueOf(fromDate.atStartOfDay());
|
||||
}
|
||||
|
||||
/** Start of the day after To, for {@code column < ?}, so the whole To day is included. */
|
||||
public Timestamp toEndExclusive() {
|
||||
return toDate == null ? null : Timestamp.valueOf(toDate.plusDays(1).atStartOfDay());
|
||||
}
|
||||
|
||||
public int offset() {
|
||||
return offset;
|
||||
}
|
||||
|
||||
public int limit() {
|
||||
return limit;
|
||||
}
|
||||
|
||||
private LocalDate date(final String key) {
|
||||
final String value = text(key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value);
|
||||
} catch (final DateTimeParseException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.date.invalid", key + " must be a date in yyyy-MM-dd form.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private int number(final String key, final int fallback) {
|
||||
final Integer value = integer(key);
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 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 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.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.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, with the accounting each movement needs.
|
||||
*
|
||||
* <ul>
|
||||
* <li>Victim's account in this bank: an account-to-account transfer (Dr suspect's control, Cr victim's control).</li>
|
||||
* <li>Victim's account elsewhere: the suspect is book-debited to the {@code I4C_RESTORATION_PAYABLE} GL (Dr suspect's
|
||||
* Savings / Current Control, Cr payable), then the fund-transfer module pays the victim's bank from that GL (Dr payable,
|
||||
* Cr outward suspense; on settlement Dr outward suspense, Cr balance with the sponsor bank). Restored money never sits
|
||||
* on a customer account, so customer deposits and the payable are both right.</li>
|
||||
* </ul>
|
||||
* Every call runs as the signed-in officer, in the caller's transaction.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class I4cMoneyMover {
|
||||
|
||||
public static final String RESTORATION_PAYABLE = "I4C_RESTORATION_PAYABLE";
|
||||
public static final String SHADOW_CREDIT = "I4C_SHADOW_CREDIT";
|
||||
public static final String COMPENSATION_RECEIVABLE = "I4C_COMPENSATION_RECEIVABLE";
|
||||
public static final String COMPENSATION_EXPENSE = "I4C_COMPENSATION_EXPENSE";
|
||||
|
||||
private final AccountTransfersWritePlatformService transfersService;
|
||||
private final FundTransferWritePlatformService fundTransferService;
|
||||
private final FundTransferLedgerService fundTransferLedger;
|
||||
private final BankIfscLookupService ifscLookup;
|
||||
private final 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 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. */
|
||||
public Long transfer(final AccountRef from, final AccountRef to, final BigDecimal amount, final String description) {
|
||||
final AccountTransferDTO dto = new AccountTransferDTO(DateUtils.getBusinessLocalDate(), amount.setScale(2, RoundingMode.DOWN),
|
||||
typeOf(from), typeOf(to), from.id(), to.id(), truncate(description, 200), null, null, null, null, null, null, null,
|
||||
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, null, null, Boolean.TRUE,
|
||||
Boolean.FALSE);
|
||||
return transfersService.transferFunds(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Booked debitToPayable(final AccountRef suspect, final BigDecimal amount, final String reference) {
|
||||
final Long payable = payableGl();
|
||||
if (payable == null) {
|
||||
throw new IllegalStateException("Map the I4C_RESTORATION_PAYABLE GL (Organisation > Clearing GL Configuration) first");
|
||||
}
|
||||
final Long control = glResolver.resolveControlGlAccountId(suspect.type(), suspect.id());
|
||||
if (control == null) {
|
||||
throw new IllegalStateException("The product of account " + suspect.accountNo() + " has no control GL mapped");
|
||||
}
|
||||
entityManager.flush();
|
||||
final LocalDate date = DateUtils.getBusinessLocalDate();
|
||||
final BigDecimal value = amount.setScale(2, RoundingMode.DOWN);
|
||||
final Long userId = context.authenticatedUser().getId();
|
||||
final long txnId = suspect.isSavings() ? bookEntries.debitSavings(suspect.id(), value, suspect.officeId(), date, userId)
|
||||
: bookEntries.debitCurrent(suspect.id(), value, suspect.officeId(), date, userId, truncate(reference, 400));
|
||||
final String ref = truncate(reference, 40) + "-D";
|
||||
accounting.createGlToGlJournalEntries(suspect.officeId(), currency(suspect), control, payable, suspect.isSavings() ? suspect.id() : null,
|
||||
ref, date, value);
|
||||
return new Booked(txnId, ref);
|
||||
}
|
||||
|
||||
/** Pays the victim's bank from the restoration payable GL through the fund-transfer module. */
|
||||
public TransferResponseData outwardFromPayable(final Long officeId, final String reference, final String channel,
|
||||
final String payeeAccountNo, final String payeeIfsc, final String payeeName, final BigDecimal amount, final String remarks) {
|
||||
final Long payable = payableGl();
|
||||
if (payable == null || !fundTransferLedger.glMode()) {
|
||||
throw new IllegalStateException("Map I4C_RESTORATION_PAYABLE and FUNDTRANSFER_OUTWARD_SUSPENSE (Clearing GL Configuration) first");
|
||||
}
|
||||
return fundTransferService.createFromGl(channel, payable, officeId, reference, payeeAccountNo, payeeIfsc, payeeName,
|
||||
amount.setScale(2, RoundingMode.DOWN), truncate(remarks, 200));
|
||||
}
|
||||
|
||||
/**
|
||||
* Victim bank: a shadow credit to our customer from the bank's own funds while the claim is examined (RBI customer
|
||||
* liability directions). Dr {@code I4C_SHADOW_CREDIT} (receivable), Cr the account's control; book credit.
|
||||
*/
|
||||
public Booked shadowCredit(final AccountRef customer, final BigDecimal amount, final String reference) {
|
||||
final Long receivable = glResolver.resolveClearingGlAccountId(SHADOW_CREDIT);
|
||||
if (receivable == null) {
|
||||
throw new IllegalStateException("Map the I4C_SHADOW_CREDIT GL (Organisation > Clearing GL Configuration) first");
|
||||
}
|
||||
if (!customer.isSavings()) {
|
||||
throw new IllegalStateException("A shadow credit is booked to savings accounts; credit a current account through a transfer");
|
||||
}
|
||||
final Long control = glResolver.resolveControlGlAccountId(customer.type(), customer.id());
|
||||
final LocalDate date = DateUtils.getBusinessLocalDate();
|
||||
final BigDecimal value = amount.setScale(2, RoundingMode.DOWN);
|
||||
final long txnId = bookEntries.creditSavings(customer.id(), value, customer.officeId(), date, context.authenticatedUser().getId());
|
||||
final String ref = truncate(reference, 40) + "-SC";
|
||||
accounting.createGlToGlJournalEntries(customer.officeId(), currency(customer), receivable, control, customer.id(), ref, date, value);
|
||||
return new Booked(txnId, ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Victim bank: restored money arrived for a customer who already had a shadow credit. The shadow credit is
|
||||
* recovered from the customer's account, clearing the receivable: Dr the account's control, Cr
|
||||
* {@code I4C_SHADOW_CREDIT}; book debit.
|
||||
*/
|
||||
public Booked regulariseShadowCredit(final AccountRef customer, final BigDecimal amount, final String reference) {
|
||||
final Long receivable = glResolver.resolveClearingGlAccountId(SHADOW_CREDIT);
|
||||
final Long control = glResolver.resolveControlGlAccountId(customer.type(), customer.id());
|
||||
if (receivable == null || control == null) {
|
||||
throw new IllegalStateException("The I4C_SHADOW_CREDIT GL or the account's control GL is not mapped");
|
||||
}
|
||||
final LocalDate date = DateUtils.getBusinessLocalDate();
|
||||
final BigDecimal value = amount.setScale(2, RoundingMode.DOWN);
|
||||
final long txnId = bookEntries.debitSavings(customer.id(), value, customer.officeId(), date, context.authenticatedUser().getId());
|
||||
final String ref = truncate(reference, 40) + "-RG";
|
||||
accounting.createGlToGlJournalEntries(customer.officeId(), currency(customer), control, receivable, customer.id(), ref, date, value);
|
||||
return new Booked(txnId, ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* RBI 2026 para 65.1T compensation to our customer: book credit of the full amount; Dr I4C_COMPENSATION_RECEIVABLE
|
||||
* for the RBI and beneficiary-bank shares and Dr I4C_COMPENSATION_EXPENSE for this bank's share, Cr the control.
|
||||
*/
|
||||
public Booked payCompensation(final AccountRef customer, final BigDecimal amount, final BigDecimal recoverable, final BigDecimal ownShare,
|
||||
final String reference) {
|
||||
final Long receivable = glResolver.resolveClearingGlAccountId(COMPENSATION_RECEIVABLE);
|
||||
final Long expense = glResolver.resolveClearingGlAccountId(COMPENSATION_EXPENSE);
|
||||
final Long control = glResolver.resolveControlGlAccountId(customer.type(), customer.id());
|
||||
if (receivable == null || expense == null || control == null) {
|
||||
throw new IllegalStateException("Map I4C_COMPENSATION_RECEIVABLE and I4C_COMPENSATION_EXPENSE (Clearing GL Configuration) first");
|
||||
}
|
||||
if (!customer.isSavings()) {
|
||||
throw new IllegalStateException("Compensation is credited to savings accounts");
|
||||
}
|
||||
final LocalDate date = DateUtils.getBusinessLocalDate();
|
||||
final long txnId = bookEntries.creditSavings(customer.id(), amount.setScale(2, RoundingMode.DOWN), customer.officeId(), date,
|
||||
context.authenticatedUser().getId());
|
||||
final String ref = truncate(reference, 40) + "-CP";
|
||||
if (recoverable.signum() > 0) {
|
||||
accounting.createGlToGlJournalEntries(customer.officeId(), currency(customer), receivable, control, customer.id(), ref, date,
|
||||
recoverable);
|
||||
}
|
||||
if (ownShare.signum() > 0) {
|
||||
accounting.createGlToGlJournalEntries(customer.officeId(), currency(customer), expense, control, customer.id(), ref, date, ownShare);
|
||||
}
|
||||
return new Booked(txnId, ref);
|
||||
}
|
||||
|
||||
public Optional<OutwardState> outwardState(final String txnRef) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT status, utr, response_message FROM m_fund_transfer WHERE txn_ref = ?", txnRef);
|
||||
if (rows.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
return Optional.of(new OutwardState(String.valueOf(r.get("status")), (String) r.get("utr"), (String) r.get("response_message")));
|
||||
}
|
||||
|
||||
private String currency(final AccountRef account) {
|
||||
final String sql = account.isSavings() ? "SELECT currency_code FROM m_savings_account WHERE id = ?"
|
||||
: "SELECT currency_code FROM m_current_account WHERE id = ?";
|
||||
final List<String> codes = jdbcTemplate.queryForList(sql, String.class, account.id());
|
||||
return codes.isEmpty() || codes.get(0) == null ? "INR" : codes.get(0);
|
||||
}
|
||||
|
||||
private static PortfolioAccountType typeOf(final AccountRef account) {
|
||||
return account.isSavings() ? PortfolioAccountType.SAVINGS : PortfolioAccountType.CURRENT;
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 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.io.IOException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Responses waiting to reach I4C.
|
||||
*
|
||||
* A response is written here in the same transaction that finishes processing the complaint, and only then sent, so a
|
||||
* failed send never loses what the bank found: it stays PENDING and is retried with growing gaps (30 s, 1 min, 2 min
|
||||
* ... capped at an hour) up to {@code fineract.i4c.outbound.max-attempts}, after which it is FAILED and shows in the
|
||||
* officer screen for a manual retry. Each message is claimed (PENDING to SENDING) before it is sent, so two dispatchers
|
||||
* never send the same one.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cOutboxService {
|
||||
|
||||
public static final String TYPE_RESPONSE = "RESPONSE";
|
||||
/** Section 5 ack sent as a call of its own, when fineract.i4c.outbound.ack-path is set. */
|
||||
public static final String TYPE_ACK = "ACK";
|
||||
public static final String PENDING = "PENDING";
|
||||
public static final String SENDING = "SENDING";
|
||||
public static final String SENT = "SENT";
|
||||
public static final String FAILED = "FAILED";
|
||||
|
||||
private static final long BASE_DELAY_SECONDS = 30;
|
||||
private static final long MAX_DELAY_SECONDS = 3600;
|
||||
/** A message left SENDING this long was abandoned by a crashed sender and is sent again. */
|
||||
private static final long STUCK_SENDING_SECONDS = 600;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cClient client;
|
||||
private final I4cProperties properties;
|
||||
|
||||
public Long enqueue(final Long requestId, final String payload) {
|
||||
return enqueue(requestId, TYPE_RESPONSE, payload);
|
||||
}
|
||||
|
||||
public Long enqueue(final Long requestId, final String messageType, final String payload) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_outbox (request_id, message_type, payload, status, attempts, next_attempt_at, created_at)
|
||||
VALUES (?,?,?,?,0,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, requestId, messageType, payload, PENDING, Timestamp.from(Instant.now()), Timestamp.from(Instant.now()));
|
||||
}
|
||||
|
||||
/** Sends everything due. Returns how many were delivered. */
|
||||
public int dispatchDue() {
|
||||
if (!properties.getOutbound().isConfigured()) {
|
||||
return 0;
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_outbox SET status = ? WHERE status = ? AND next_attempt_at < ?", PENDING, SENDING,
|
||||
Timestamp.from(Instant.now().minusSeconds(STUCK_SENDING_SECONDS)));
|
||||
final List<Long> due = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_i4c_outbox WHERE status = ? AND next_attempt_at <= ?"
|
||||
+ " ORDER BY CASE WHEN message_type = 'ACK' THEN 0 ELSE 1 END, next_attempt_at, id LIMIT 100", Long.class,
|
||||
PENDING, Timestamp.from(Instant.now()));
|
||||
int sent = 0;
|
||||
for (final Long id : due) {
|
||||
if (send(id)) {
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one message now if it is due; used right after processing so a reply does not wait for the next job run.
|
||||
*/
|
||||
public boolean send(final Long outboxId) {
|
||||
final int claimed = jdbcTemplate.update("UPDATE m_i4c_outbox SET status = ?, next_attempt_at = ? WHERE id = ? AND status = ?",
|
||||
SENDING, Timestamp.from(Instant.now()), outboxId, PENDING);
|
||||
if (claimed == 0) {
|
||||
return false;
|
||||
}
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||
SELECT o.payload, o.attempts, o.message_type, r.acknowledgement_no FROM m_i4c_outbox o JOIN m_i4c_request r ON r.id = o.request_id
|
||||
WHERE o.id = ?
|
||||
""", outboxId);
|
||||
final int attempts = ((Number) row.get("attempts")).intValue() + 1;
|
||||
try {
|
||||
final I4cClient.Result result = client.send((String) row.get("message_type"), (String) row.get("payload"),
|
||||
(String) row.get("acknowledgement_no"));
|
||||
if (result.isSuccess()) {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_outbox SET status = ?, attempts = ?, http_status = ?, response_body = ?, last_error = NULL, sent_at = ?
|
||||
WHERE id = ?
|
||||
""", SENT, attempts, result.httpStatus(), truncate(result.body()), Timestamp.from(Instant.now()), outboxId);
|
||||
return true;
|
||||
}
|
||||
failed(outboxId, attempts, result.httpStatus(), result.body(),
|
||||
"I4C answered HTTP " + result.httpStatus() + (result.responseCode() == null ? "" : " code " + result.responseCode()));
|
||||
} catch (final IOException | RuntimeException e) {
|
||||
failed(outboxId, attempts, null, null, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sends this complaint's pending messages of one type now - the ack before the response that follows it. */
|
||||
public void sendPending(final Long requestId, final String messageType) {
|
||||
for (final Long id : jdbcTemplate.queryForList("SELECT id FROM m_i4c_outbox WHERE request_id = ? AND message_type = ? AND status = ?",
|
||||
Long.class, requestId, messageType, PENDING)) {
|
||||
send(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Officer action: send a FAILED message again, with a fresh set of attempts. */
|
||||
public void retry(final Long outboxId) {
|
||||
final int updated = jdbcTemplate.update(
|
||||
"UPDATE m_i4c_outbox SET status = ?, attempts = 0, next_attempt_at = ? WHERE id = ? AND status = ?", PENDING,
|
||||
Timestamp.from(Instant.now()), outboxId, FAILED);
|
||||
if (updated == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.i4c.outbox.not.failed", "Only a FAILED message can be retried.");
|
||||
}
|
||||
}
|
||||
|
||||
private void failed(final Long outboxId, final int attempts, final Integer httpStatus, final String body, final String error) {
|
||||
final int max = Math.max(1, properties.getOutbound().getMaxAttempts());
|
||||
final boolean giveUp = attempts >= max;
|
||||
final long delay = Math.min(MAX_DELAY_SECONDS, BASE_DELAY_SECONDS * (1L << Math.min(attempts - 1, 20)));
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_outbox SET status = ?, attempts = ?, next_attempt_at = ?, http_status = ?, response_body = ?, last_error = ?
|
||||
WHERE id = ?
|
||||
""", giveUp ? FAILED : PENDING, attempts, Timestamp.from(Instant.now().plusSeconds(delay)), httpStatus, truncate(body),
|
||||
truncate(error), outboxId);
|
||||
if (giveUp) {
|
||||
log.error("I4C response {} FAILED after {} attempts: {}", outboxId, attempts, error);
|
||||
} else {
|
||||
log.warn("I4C response {} attempt {} failed, next in {}s: {}", outboxId, attempts, delay, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static String truncate(final String value) {
|
||||
return value == null || value.length() <= 10_000 ? value : value.substring(0, 10_000);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Processes complaints away from the request that delivered them.
|
||||
*
|
||||
* I4C expects the ack straight away (section 5), and acting on a complaint means account look-ups, holds and tracing
|
||||
* that should not keep its connection open; so the ack is returned at once and the work happens on the I4C executor.
|
||||
* The I4C Dispatch job picks up anything this did not finish (a restart in between, a failure to retry), so the
|
||||
* executor is the fast path and never the only one.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class I4cProcessingRunner {
|
||||
|
||||
/** A request left PROCESSING this long belonged to a worker that died; the dispatch job takes it over. */
|
||||
private static final long STUCK_SECONDS = 600;
|
||||
/** Gap before a RETRY request is tried again. */
|
||||
private static final long RETRY_AFTER_SECONDS = 60;
|
||||
|
||||
// Deferred: the processor needs the request service's tables and the request service needs this runner.
|
||||
private final ObjectProvider<I4cComplaintProcessor> processor;
|
||||
private final ObjectProvider<I4cOutboxService> outbox;
|
||||
private final ObjectProvider<I4cSystemContext> systemContext;
|
||||
private final ObjectProvider<I4cIntimationService> intimation;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public I4cProcessingRunner(final ObjectProvider<I4cComplaintProcessor> processor, final ObjectProvider<I4cOutboxService> outbox,
|
||||
final ObjectProvider<I4cSystemContext> systemContext, final ObjectProvider<I4cIntimationService> intimation,
|
||||
final JdbcTemplate jdbcTemplate) {
|
||||
this.processor = processor;
|
||||
this.outbox = outbox;
|
||||
this.systemContext = systemContext;
|
||||
this.intimation = intimation;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Async(I4cExecutorConfig.EXECUTOR)
|
||||
public void processAsync(final Long requestId, final FineractPlatformTenant tenant) {
|
||||
systemContext.getObject().runInTenant(tenant, () -> processAndSend(requestId));
|
||||
}
|
||||
|
||||
/** Processes one complaint and sends the reply immediately; the caller provides tenant and user context. */
|
||||
public void processAndSend(final Long requestId) {
|
||||
try {
|
||||
final Long outboxId = processor.getObject().process(requestId);
|
||||
// A separate ack call, when configured, must reach I4C before the response it acknowledges.
|
||||
outbox.getObject().sendPending(requestId, I4cOutboxService.TYPE_ACK);
|
||||
if (outboxId != null) {
|
||||
outbox.getObject().send(outboxId);
|
||||
}
|
||||
// Tell the account holder as soon as money is held, on whichever automatic channel is switched on.
|
||||
intimation.getObject().intimateForRequest(requestId);
|
||||
} catch (final RuntimeException e) {
|
||||
log.error("I4C request {} could not be processed now; the dispatch job will try again", requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** For the dispatch job: complaints received but not processed, retries that are due, and abandoned work. */
|
||||
public int processPending() {
|
||||
jdbcTemplate.update("UPDATE m_i4c_request SET status = ? WHERE status = ? AND received_at < ? AND processed_at IS NULL",
|
||||
I4cComplaintProcessor.RETRY, I4cComplaintProcessor.PROCESSING, Timestamp.from(Instant.now().minusSeconds(STUCK_SECONDS)));
|
||||
final List<Long> due = jdbcTemplate.queryForList("""
|
||||
SELECT id FROM m_i4c_request
|
||||
WHERE (status = ? AND received_at < ?) OR status = ?
|
||||
ORDER BY received_at
|
||||
LIMIT 50
|
||||
""", Long.class, I4cComplaintProcessor.RECEIVED, Timestamp.from(Instant.now().minusSeconds(RETRY_AFTER_SECONDS)),
|
||||
I4cComplaintProcessor.RETRY);
|
||||
due.forEach(this::processAndSend);
|
||||
return due.size();
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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.util.List;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cAcks;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cComplaint;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* Takes in a complaint push: stores it, answers with a job id, and starts processing once the store is committed.
|
||||
*
|
||||
* Idempotent per (acknowledgement_no, content). I4C retries a push when it does not get an ack in time; a repeat with
|
||||
* the same incidents gets the same job_id back and starts nothing new. The same acknowledgement number with different
|
||||
* incidents (the next layer of the same complaint, say) is a new request with its own job_id.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cRequestService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cProcessingRunner runner;
|
||||
private final I4cProperties properties;
|
||||
private final I4cOutboxService outbox;
|
||||
|
||||
public record Accepted(Long requestId, String jobId, boolean duplicate) {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Accepted accept(final I4cComplaint complaint, final String rawPayload, final String sourceIp) {
|
||||
final String hash = I4cInboundAuthService.sha256(contentKey(complaint));
|
||||
final List<java.util.Map<String, Object>> existing = jdbcTemplate.queryForList(
|
||||
"SELECT id, job_id FROM m_i4c_request WHERE acknowledgement_no = ? AND payload_hash = ? ORDER BY id LIMIT 1",
|
||||
complaint.acknowledgementNo(), hash);
|
||||
if (!existing.isEmpty()) {
|
||||
return new Accepted(((Number) existing.get(0).get("id")).longValue(), (String) existing.get(0).get("job_id"), true);
|
||||
}
|
||||
final String jobId = "BANKS-" + UUID.randomUUID();
|
||||
final Long requestId = jdbcTemplate.queryForObject(
|
||||
"""
|
||||
INSERT INTO m_i4c_request (acknowledgement_no, job_id, sub_category, requestor, payer_bank, payer_bank_code, mode_of_payment,
|
||||
payer_mobile_number, payer_account_number, state, district, transaction_type, wallet, max_layer,
|
||||
payload_hash, raw_payload, source_ip, status, processing_attempts, received_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class, complaint.acknowledgementNo(), jobId, cut(complaint.subCategory(), 200), cut(complaint.requestor(), 50),
|
||||
cut(complaint.payerBank(), 200), cut(complaint.payerBankCode(), 10), cut(complaint.modeOfPayment(), 30),
|
||||
cut(complaint.payerMobileNumber(), 20), complaint.payerAccountNumber(), cut(complaint.state(), 100),
|
||||
cut(complaint.district(), 100), cut(complaint.transactionType(), 50), cut(complaint.wallet(), 100), complaint.maxLayer(),
|
||||
hash, I4cApiLogService.mask(rawPayload), cut(sourceIp, 64), I4cComplaintProcessor.RECEIVED, Timestamp.from(Instant.now()));
|
||||
for (final I4cComplaint.Incident i : complaint.incidents()) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_incident (request_id, seq_no, layer, amount, disputed_amount, rrn, transaction_date, transaction_time,
|
||||
card_first6, card_last4, card_length)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", requestId, i.seqNo(), i.layer(), i.amount(), i.disputedAmount(), i.rrn(),
|
||||
i.transactionDate() == null ? null : Date.valueOf(i.transactionDate()), cut(i.transactionTime(), 12),
|
||||
cut(i.cardFirst6(), 6), cut(i.cardLast4(), 4), cut(i.cardLength(), 4));
|
||||
}
|
||||
final String ackPath = properties.getOutbound().getAckPath();
|
||||
if (ackPath != null && !ackPath.isBlank()) {
|
||||
// Written with the request, so it is queued before the response the processing will write.
|
||||
outbox.enqueue(requestId, I4cOutboxService.TYPE_ACK, I4cAcks.ack(I4cCodes.ACK_SUCCESS, complaint.acknowledgementNo(), jobId));
|
||||
}
|
||||
startAfterCommit(requestId);
|
||||
return new Accepted(requestId, jobId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing starts only once the request is committed, so the worker never reads a request that is not there yet.
|
||||
*/
|
||||
private void startAfterCommit(final Long requestId) {
|
||||
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
runner.processAsync(requestId, tenant);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
runner.processAsync(requestId, tenant);
|
||||
}
|
||||
}
|
||||
|
||||
/** What makes two pushes "the same": the account and every incident's reference, amounts, date and layer. */
|
||||
static String contentKey(final I4cComplaint complaint) {
|
||||
final StringBuilder key = new StringBuilder(complaint.acknowledgementNo()).append('|').append(complaint.payerAccountNumber());
|
||||
for (final I4cComplaint.Incident i : complaint.incidents()) {
|
||||
key.append('|').append(i.rrn()).append(',').append(i.amount().stripTrailingZeros().toPlainString()).append(',')
|
||||
.append(i.disputedAmount().stripTrailingZeros().toPlainString()).append(',').append(i.transactionDate()).append(',')
|
||||
.append(i.layer());
|
||||
}
|
||||
return key.toString();
|
||||
}
|
||||
|
||||
private static String cut(final String value, final int max) {
|
||||
return value == null || value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 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 com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes.TxnType;
|
||||
|
||||
/**
|
||||
* Writes the "Response Message from BANKS to I4C-MHA" (specification section 6): {@code acknowledgement_no},
|
||||
* {@code job_id} and one {@code transactions[]} entry per finding.
|
||||
*
|
||||
* Field names follow the specification's success samples, which are what I4C parses; where the table and the sample
|
||||
* disagree (the table says {@code rrn}, the Money Transfer sample says {@code rrn_transaction_id}) the sample wins.
|
||||
* Every value is a string, amounts with two decimals, as in the samples.
|
||||
*
|
||||
* Layer 0 is the victim's own account: the specification makes phone, PAN, effective balance and root IFSC optional
|
||||
* there and gives defaults ({@code 0000000000}, {@code "Layer 0"}, {@code "0"}), which are used.
|
||||
*/
|
||||
public final class I4cResponseBuilder {
|
||||
|
||||
static final String LAYER0_PHONE = "0000000000";
|
||||
static final String LAYER0_TEXT = "Layer 0";
|
||||
|
||||
private I4cResponseBuilder() {}
|
||||
|
||||
/**
|
||||
* What every entry of one incident shares: the root (this bank's) account, the reported RRN, the account holder.
|
||||
*/
|
||||
public record Root(String accountNumber, String rrn, int bankId, int layer, BigDecimal disputedAmount, BigDecimal effectiveBalance,
|
||||
String phone, String email, String pan, String ifsc) {
|
||||
}
|
||||
|
||||
public static JsonObject envelope(final String acknowledgementNo, final String jobId, final List<JsonObject> transactions) {
|
||||
final JsonObject out = new JsonObject();
|
||||
out.addProperty("acknowledgement_no", acknowledgementNo);
|
||||
out.addProperty("job_id", jobId);
|
||||
final JsonArray array = new JsonArray();
|
||||
transactions.forEach(array::add);
|
||||
out.add("transactions", array);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** txn_type_id 1: the amount now held on the root account. */
|
||||
public static JsonObject putOnHold(final Root root, final BigDecimal amountHeld, final String transactionDateTime,
|
||||
final String remarks) {
|
||||
final JsonObject e = new JsonObject();
|
||||
e.addProperty("root_account_number", root.accountNumber());
|
||||
e.addProperty("root_rrn_transaction_id", root.rrn());
|
||||
e.addProperty("root_bankid", String.valueOf(root.bankId()));
|
||||
e.addProperty("ifsc_code", nz(root.ifsc()));
|
||||
e.addProperty("amount", money(amountHeld));
|
||||
e.addProperty("transaction_datetime", nz(transactionDateTime));
|
||||
holder(e, root);
|
||||
e.addProperty("remarks", nz(remarks));
|
||||
type(e, TxnType.PUT_ON_HOLD);
|
||||
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
|
||||
tail(e, root);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** txn_type_id 3: money moved on to another account, here or at another bank. */
|
||||
public static JsonObject moneyTransferTo(final Root root, final String rrnTransactionId, final String payeeBank,
|
||||
final int payeeBankCode, final String payeeAccountNumber, final String payeeIfsc, final BigDecimal amount,
|
||||
final BigDecimal disputedAmount, final String transactionDateTime, final String remarks) {
|
||||
final JsonObject e = new JsonObject();
|
||||
e.addProperty("rrn_transaction_id", nz(rrnTransactionId));
|
||||
// The section 6 table names this key "rrn"; its sample "rrn_transaction_id". Both are sent, with one value.
|
||||
e.addProperty("rrn", nz(rrnTransactionId));
|
||||
e.addProperty("payee_bank", nz(payeeBank));
|
||||
e.addProperty("payee_bank_code", String.valueOf(payeeBankCode));
|
||||
e.addProperty("payee_account_number", nz(payeeAccountNumber));
|
||||
e.addProperty("amount", money(amount));
|
||||
e.addProperty("transaction_datetime", nz(transactionDateTime));
|
||||
e.addProperty("disputed_amount", money(disputedAmount));
|
||||
e.addProperty("ifsc_code", nz(payeeIfsc));
|
||||
type(e, TxnType.MONEY_TRANSFER_TO);
|
||||
holder(e, root);
|
||||
e.addProperty("root_account_number", root.accountNumber());
|
||||
e.addProperty("root_rrn_transaction_id", root.rrn());
|
||||
e.addProperty("root_bankid", String.valueOf(root.bankId()));
|
||||
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
|
||||
e.addProperty("remarks", nz(remarks));
|
||||
tail(e, root);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** txn_type_id 14: withdrawn at the branch against a cheque. */
|
||||
public static JsonObject withdrawalThroughCheque(final Root root, final String chequeNo, final String withdrawalDate,
|
||||
final BigDecimal amount, final BigDecimal disputedAmount, final String location, final String managerName,
|
||||
final String managerNumber, final String remarks) {
|
||||
final JsonObject e = new JsonObject();
|
||||
e.addProperty("root_account_number", root.accountNumber());
|
||||
e.addProperty("root_rrn_transaction_id", root.rrn());
|
||||
e.addProperty("root_bankid", String.valueOf(root.bankId()));
|
||||
e.addProperty("account_number", root.accountNumber());
|
||||
e.addProperty("ifsc_code", nz(root.ifsc()));
|
||||
e.addProperty("cheque_no", nz(chequeNo));
|
||||
e.addProperty("withdrawal_date", nz(withdrawalDate));
|
||||
e.addProperty("amount", money(amount));
|
||||
e.addProperty("disputed_amount", money(disputedAmount));
|
||||
e.addProperty("location", nz(location));
|
||||
e.addProperty("managername", nz(managerName));
|
||||
e.addProperty("managernumber", nz(managerNumber));
|
||||
holder(e, root);
|
||||
e.addProperty("remarks", nz(remarks));
|
||||
type(e, TxnType.WITHDRAWAL_CHEQUE);
|
||||
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
|
||||
tail(e, root);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** txn_type_id 5: withdrawn at an ATM. Field order of the specification's ATM sample. */
|
||||
public static JsonObject withdrawalThroughAtm(final Root root, final BigDecimal amount, final String transactionDateTime,
|
||||
final String atmId, final String placeOfAtm, final String atmOfBank, final String remarks) {
|
||||
final JsonObject e = rootHead(root);
|
||||
e.addProperty("amount", money(amount));
|
||||
e.addProperty("transaction_datetime", nz(transactionDateTime));
|
||||
holder(e, root);
|
||||
e.addProperty("disputed_amount", money(root.disputedAmount()));
|
||||
e.addProperty("remarks", nz(remarks));
|
||||
e.addProperty("atm_id", nz(atmId));
|
||||
e.addProperty("place_of_atm", nz(placeOfAtm));
|
||||
e.addProperty("atm_of_bank", nz(atmOfBank));
|
||||
type(e, TxnType.WITHDRAWAL_ATM);
|
||||
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
|
||||
tail(e, root);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** txn_type_id 6: withdrawn through AEPS. The table calls the reference "rrn", the sample "rrn_transaction_id". */
|
||||
public static JsonObject withdrawalThroughAeps(final Root root, final String rrnTransactionId, final BigDecimal amount,
|
||||
final String remarks) {
|
||||
final JsonObject e = rootHead(root);
|
||||
e.addProperty("rrn_transaction_id", nz(rrnTransactionId));
|
||||
e.addProperty("rrn", nz(rrnTransactionId));
|
||||
e.addProperty("amount", money(amount));
|
||||
e.addProperty("disputed_amount", money(root.disputedAmount()));
|
||||
holder(e, root);
|
||||
e.addProperty("remarks", nz(remarks));
|
||||
type(e, TxnType.WITHDRAWAL_AEPS);
|
||||
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
|
||||
tail(e, root);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** txn_type_id 11: spent at a merchant's POS terminal. */
|
||||
public static JsonObject withdrawalThroughPos(final Root root, final BigDecimal amount, final String transactionDateTime,
|
||||
final String mid, final String tid, final String approvalCode, final String merchantName, final String posTransactionId,
|
||||
final String remarks) {
|
||||
final JsonObject e = rootHead(root);
|
||||
e.addProperty("amount", money(amount));
|
||||
e.addProperty("transaction_datetime", nz(transactionDateTime));
|
||||
holder(e, root);
|
||||
e.addProperty("disputed_amount", money(root.disputedAmount()));
|
||||
e.addProperty("remarks", nz(remarks));
|
||||
e.addProperty("mid", nz(mid));
|
||||
e.addProperty("tid", nz(tid));
|
||||
e.addProperty("approval_code", nz(approvalCode));
|
||||
e.addProperty("merchant_name", nz(merchantName));
|
||||
e.addProperty("pos_transaction_id", nz(posTransactionId));
|
||||
type(e, TxnType.WITHDRAWAL_POS);
|
||||
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
|
||||
tail(e, root);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static JsonObject rootHead(final Root root) {
|
||||
final JsonObject e = new JsonObject();
|
||||
e.addProperty("root_account_number", root.accountNumber());
|
||||
e.addProperty("root_rrn_transaction_id", root.rrn());
|
||||
e.addProperty("root_bankid", String.valueOf(root.bankId()));
|
||||
return e;
|
||||
}
|
||||
|
||||
/** txn_type_id 17: amounts of Rs 500 or less that are not traced further. */
|
||||
public static JsonObject othersUpTo500(final Root root, final BigDecimal amount, final String remarks) {
|
||||
final JsonObject e = new JsonObject();
|
||||
e.addProperty("amount", money(amount));
|
||||
e.addProperty("disputed_amount", money(root.disputedAmount()));
|
||||
type(e, TxnType.OTHERS_UPTO_500);
|
||||
holder(e, root);
|
||||
e.addProperty("root_account_number", root.accountNumber());
|
||||
e.addProperty("root_rrn_transaction_id", root.rrn());
|
||||
e.addProperty("root_bankid", String.valueOf(root.bankId()));
|
||||
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
|
||||
e.addProperty("remarks", nz(remarks));
|
||||
tail(e, root);
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* An incident that could not be acted on: status_code 01 "Record not found" (no such account here) or 02 "Invalid
|
||||
* RRN" (the account exists but the transaction does not). Shape of the specification's failed-transaction sample.
|
||||
*/
|
||||
public static JsonObject failure(final String rootAccountNumber, final String rrn, final int bankId, final BigDecimal amount,
|
||||
final BigDecimal disputedAmount, final String transactionDateTime, final String statusCode, final String remarks) {
|
||||
final JsonObject e = new JsonObject();
|
||||
e.addProperty("root_rrn_transaction_id", nz(rrn));
|
||||
e.addProperty("root_bankid", String.valueOf(bankId));
|
||||
e.addProperty("root_account_number", nz(rootAccountNumber));
|
||||
e.addProperty("amount", money(amount));
|
||||
e.addProperty("transaction_datetime", nz(transactionDateTime));
|
||||
e.addProperty("disputed_amount", money(disputedAmount));
|
||||
e.addProperty("remarks", nz(remarks));
|
||||
e.addProperty("status_code", statusCode);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static void holder(final JsonObject e, final Root root) {
|
||||
final boolean layer0 = root.layer() == 0;
|
||||
e.addProperty("phone_number", layer0 && isBlank(root.phone()) ? LAYER0_PHONE : nz(root.phone()));
|
||||
e.addProperty("email", nz(root.email()));
|
||||
e.addProperty("pan_number", layer0 && isBlank(root.pan()) ? LAYER0_TEXT : nz(root.pan()));
|
||||
}
|
||||
|
||||
private static void tail(final JsonObject e, final Root root) {
|
||||
final boolean layer0 = root.layer() == 0;
|
||||
e.addProperty("root_effective_balance", layer0 && root.effectiveBalance() == null ? "0" : money(root.effectiveBalance()));
|
||||
e.addProperty("root_ifsc_code", layer0 && isBlank(root.ifsc()) ? LAYER0_TEXT : nz(root.ifsc()));
|
||||
}
|
||||
|
||||
private static void type(final JsonObject e, final TxnType type) {
|
||||
e.addProperty("txn_type", type.label());
|
||||
e.addProperty("txn_type_id", String.valueOf(type.id()));
|
||||
}
|
||||
|
||||
static String money(final BigDecimal value) {
|
||||
return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
private static String nz(final String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static boolean isBlank(final String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cMoneyMover.OutwardState;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Carries out an approved MRM restoration on the money, and follows the outward transfer to the end.
|
||||
*
|
||||
* <ul>
|
||||
* <li>Victim's account in this bank: account-to-account transfer from the suspect account, in the approval's
|
||||
* transaction. RESTORED at once; the CBS transfer id is the transaction id entered on MRM.</li>
|
||||
* <li>Victim's account elsewhere: in the approval's transaction the suspect account is book-debited to the
|
||||
* {@code I4C_RESTORATION_PAYABLE} GL (status IN_POOL, i.e. held in the payable); then the fund-transfer module pays the
|
||||
* victim's bank from that GL (TRANSFER_PENDING until it settles, when the outward suspense clears to the sponsor-bank
|
||||
* settlement account). A refused or failed outward returns the money to the payable for a retry, possibly to
|
||||
* corrected victim details (MRM "refer back to victim bank"); it never returns to the suspect.</li>
|
||||
* </ul>
|
||||
* On RESTORED the suspect's 15-day window to contest the refund (MHA SOP, process 1) starts.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class I4cRestorationExecutor {
|
||||
|
||||
public static final String ASSESSED = "ASSESSED";
|
||||
public static final String REQUESTED = "REQUESTED";
|
||||
public static final String AWAITING_APPROVAL = "AWAITING_APPROVAL";
|
||||
public static final String IN_POOL = "IN_POOL";
|
||||
public static final String TRANSFER_PENDING = "TRANSFER_PENDING";
|
||||
public static final String RESTORED = "RESTORED";
|
||||
public static final String CLOSED = "CLOSED";
|
||||
|
||||
public static final Set<String> CHANNELS = Set.of("NEFT", "IMPS", "RTGS");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cAccountGateway gateway;
|
||||
private final I4cMoneyMover mover;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
/** In the approval's transaction: move {@code amount}, already released from hold, out of the suspect account. */
|
||||
public void carryOut(final Long restorationId, final AccountRef suspect, final BigDecimal amount, final String ackNo) {
|
||||
final Map<String, Object> r = jdbcTemplate.queryForMap("SELECT * FROM m_i4c_restoration WHERE id = ? FOR UPDATE", restorationId);
|
||||
if (!AWAITING_APPROVAL.equals(r.get("status"))) {
|
||||
throw new IllegalStateException("Restoration " + restorationId + " is " + r.get("status") + ", not awaiting approval");
|
||||
}
|
||||
final String victimAccount = (String) r.get("victim_account_no");
|
||||
final String description = "I4C MRM restoration Ack " + ackNo + (r.get("mrm_request_no") == null ? "" : " " + r.get("mrm_request_no"));
|
||||
final Timestamp now = Timestamp.from(Instant.now());
|
||||
if (mover.isOurs((String) r.get("victim_ifsc"))) {
|
||||
final AccountRef victim = gateway.resolve(victimAccount)
|
||||
.orElseThrow(() -> new IllegalStateException("Victim account " + victimAccount + " is not an account of this bank"));
|
||||
if (victim.status() != 300) {
|
||||
throw new IllegalStateException("Victim account " + victimAccount + " is not active");
|
||||
}
|
||||
if (victim.type().equals(suspect.type()) && victim.id().equals(suspect.id())) {
|
||||
throw new IllegalStateException("The victim account cannot be the account on hold");
|
||||
}
|
||||
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 = ?, challenge_until = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", 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;
|
||||
}
|
||||
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 = ?, debit_txn_id = ?, payable_journal_ref = ?, restored_amount = ?, transfer_error = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""", 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());
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
try {
|
||||
sendOutward(restorationId);
|
||||
} catch (final RuntimeException e) {
|
||||
log.warn("I4C restoration {}: outward transfer not sent: {}", restorationId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a pooled restoration to the victim's bank through the fund-transfer module, in its own transaction. The
|
||||
* outcome is recorded either way; the exception, if any, is rethrown for an officer's retry to show.
|
||||
*/
|
||||
public String sendOutward(final Long restorationId) {
|
||||
final Map<String, Object> r = jdbcTemplate.queryForMap("SELECT * FROM m_i4c_restoration WHERE id = ?", restorationId);
|
||||
if (!IN_POOL.equals(r.get("status"))) {
|
||||
return (String) r.get("status");
|
||||
}
|
||||
try {
|
||||
return newTransaction().execute(status -> {
|
||||
final String channel = r.get("transfer_channel") == null ? "NEFT" : (String) r.get("transfer_channel");
|
||||
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);
|
||||
final String next = settled ? RESTORED : failed ? IN_POOL : TRANSFER_PENDING;
|
||||
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,
|
||||
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, settled,
|
||||
challengeUntil(), now, restorationId);
|
||||
return next;
|
||||
});
|
||||
} catch (final RuntimeException e) {
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET transfer_error = ?, updated_at = ? WHERE id = ?",
|
||||
trim(e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(), 1000), Timestamp.from(Instant.now()),
|
||||
restorationId);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 restoration payable. Run by the I4C Hold Review job.
|
||||
*/
|
||||
public int syncOutward() {
|
||||
int changed = 0;
|
||||
final List<Map<String, Object>> pending = jdbcTemplate.queryForList(
|
||||
"SELECT id, fund_transfer_ref FROM m_i4c_restoration WHERE status = ? AND fund_transfer_ref IS NOT NULL", TRANSFER_PENDING);
|
||||
for (final Map<String, Object> row : pending) {
|
||||
final Optional<OutwardState> state = mover.outwardState((String) row.get("fund_transfer_ref"));
|
||||
if (state.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
final String s = state.get().status().toUpperCase(Locale.ROOT);
|
||||
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 = ?, challenge_until = ?, updated_at = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", 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 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,
|
||||
row.get("id"), s);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** A RESTORE request was rejected or failed: the restoration goes back to waiting for the bank's action. */
|
||||
public void requestEnded(final Long restorationId, final String reason) {
|
||||
if (restorationId == null) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET status = ?, bank_action = 'PENDING', action_request_id = NULL, transfer_error = ?, updated_at = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", REQUESTED, trim(reason, 1000), Timestamp.from(Instant.now()), restorationId, AWAITING_APPROVAL);
|
||||
}
|
||||
|
||||
private TransactionTemplate newTransaction() {
|
||||
final TransactionTemplate tx = new TransactionTemplate(transactionManager);
|
||||
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
return tx;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
/**
|
||||
* 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.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRestorationExecutor.ASSESSED;
|
||||
import static org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRestorationExecutor.AWAITING_APPROVAL;
|
||||
import static org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRestorationExecutor.CLOSED;
|
||||
import static org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRestorationExecutor.IN_POOL;
|
||||
import static org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRestorationExecutor.REQUESTED;
|
||||
import static org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRestorationExecutor.RESTORED;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
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;
|
||||
|
||||
/**
|
||||
* The bank's record of MRM money restoration, following the MRM user manual.
|
||||
*
|
||||
* As beneficiary bank (money held here): the bank first assesses each complaint (restoration possible by attribution
|
||||
* or pro-rata, or not possible and why), which is what it updates on MRM; when the victim's request arrives from the
|
||||
* IO it records it, checks the IO's papers, and takes one of the five MRM bank actions. "Compliance" raises a RESTORE
|
||||
* request that a checker approves, which moves the money (see {@link I4cRestorationExecutor}).
|
||||
*
|
||||
* As victim bank (our customer lost the money): it records restored money received, or why none is expected.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cRestorationService {
|
||||
|
||||
public static final String BENEFICIARY = "BENEFICIARY_BANK";
|
||||
public static final String VICTIM = "VICTIM_BANK";
|
||||
public static final Set<String> POSSIBLE = Set.of("ATTRIBUTION", "PRO_RATA");
|
||||
public static final Set<String> NOT_POSSIBLE = Set.of("NOT_POSSIBLE_ALREADY_REFUNDED", "NOT_POSSIBLE_NO_BALANCE", "NOT_POSSIBLE_OTHERS");
|
||||
public static final Set<String> BANK_ACTIONS = Set.of("COMPLIED", "NOT_COMPLIED", "REFER_BACK_LEA", "REFER_BACK_VICTIM_BANK", "CONSENSUS");
|
||||
public static final Set<String> REFER_BACK_LEA_REASONS = Set.of("ISSUE_COURT_ORDER", "ISSUE_FIR", "ISSUE_ORDER_AND_FIR",
|
||||
"ISSUE_INDEMNITY_BOND");
|
||||
/** Who caused the fraudulent transaction; the governing RBI framework follows from its date (see I4cCustomerLiability). */
|
||||
public static final Set<String> LIABILITY_CATEGORIES = I4cCustomerLiability.CATEGORIES;
|
||||
/** SOP process 1: the bank credits the victim within 15 days of the IO's notice / order. */
|
||||
public static final int COMPLIANCE_DAYS = 15;
|
||||
public static final Set<String> VICTIM_STATUSES = Set.of("ALREADY_REFUNDED_BANK", "ALREADY_REFUNDED_POLICE_NOTICE",
|
||||
"ALREADY_REFUNDED_COURT_ORDER", "DECLINED_REFUND", "DECLINED_TO_PURSUE", "RECEIVED_THROUGH_COURT", "NOT_REACHABLE");
|
||||
private static final Pattern IFSC = Pattern.compile("^[A-Z]{4}0[A-Z0-9]{6}$");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cHoldService holdService;
|
||||
private final I4cRestorationExecutor executor;
|
||||
private final I4cMoneyMover mover;
|
||||
private final I4cAccountGateway gateway;
|
||||
private final I4cDocumentService documents;
|
||||
private final I4cProperties properties;
|
||||
|
||||
/** The restorable amount and its basis, as the bank updates it on MRM ("Money Distribution Process"). */
|
||||
@Transactional
|
||||
public Long assess(final Long holdId, final String basis, final BigDecimal restorableAmount, final String remarks, final AppUser user) {
|
||||
final Map<String, Object> hold = holdService.loadHold(holdId);
|
||||
final String b = upper(basis);
|
||||
if (!POSSIBLE.contains(b) && !NOT_POSSIBLE.contains(b)) {
|
||||
throw error("restoration.basis", "Basis must be one of " + POSSIBLE + " or " + NOT_POSSIBLE + ".");
|
||||
}
|
||||
final BigDecimal held = (BigDecimal) hold.get("amount_held");
|
||||
final BigDecimal amount = NOT_POSSIBLE.contains(b) ? BigDecimal.ZERO : restorableAmount;
|
||||
if (POSSIBLE.contains(b) && (amount == null || amount.signum() <= 0 || amount.compareTo(held) > 0)) {
|
||||
throw error("restoration.amount", "The restorable amount must be more than zero and at most the " + held.toPlainString() + " held.");
|
||||
}
|
||||
if (NOT_POSSIBLE.contains(b) && blank(remarks)) {
|
||||
throw error("restoration.reason", "Say why restoration is not possible.");
|
||||
}
|
||||
final Timestamp now = now();
|
||||
final List<Map<String, Object>> open = jdbcTemplate.queryForList(
|
||||
"SELECT id, status FROM m_i4c_restoration WHERE hold_id = ? AND role = ? AND status IN (?, ?) ORDER BY id DESC FOR UPDATE", holdId,
|
||||
BENEFICIARY, ASSESSED, REQUESTED);
|
||||
if (!open.isEmpty()) {
|
||||
final Long id = ((Number) open.get(0).get("id")).longValue();
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET basis = ?, restorable_amount = ?, basis_remarks = ?, updated_at = ? WHERE id = ?", b,
|
||||
amount, trim(remarks, 1000), now, id);
|
||||
return id;
|
||||
}
|
||||
final Integer busy = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_restoration WHERE hold_id = ? AND role = ? AND status IN (?, ?, ?)", Integer.class, holdId, BENEFICIARY,
|
||||
AWAITING_APPROVAL, IN_POOL, I4cRestorationExecutor.TRANSFER_PENDING);
|
||||
if (busy != null && busy > 0) {
|
||||
throw error("restoration.busy", "A restoration on this hold is being carried out; finish it first.");
|
||||
}
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_restoration (role, hold_id, request_id, acknowledgement_no, basis, restorable_amount, basis_remarks, status,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, BENEFICIARY, holdId, hold.get("request_id"), hold.get("acknowledgement_no"), b, amount, trim(remarks, 1000),
|
||||
ASSESSED, name(user), now, now);
|
||||
}
|
||||
|
||||
/** The victim's MRM request, as the IO sent it to this bank: MR id, refund account, and the IO's papers. */
|
||||
@Transactional
|
||||
public void recordRequest(final Long id, final Map<String, Object> f, final AppUser user) {
|
||||
final Map<String, Object> r = loadBeneficiary(id);
|
||||
if (!ASSESSED.equals(r.get("status")) && !REQUESTED.equals(r.get("status")) && !IN_POOL.equals(r.get("status"))) {
|
||||
throw error("restoration.status", "The request details cannot change once the restoration is " + r.get("status") + ".");
|
||||
}
|
||||
final String accountNo = text(f, "victimAccountNo");
|
||||
if (blank(text(f, "mrmRequestNo")) || blank(text(f, "victimName")) || blank(accountNo)) {
|
||||
throw error("restoration.request", "Enter the MRM request id, the victim's name and the refund account.");
|
||||
}
|
||||
final String ifsc = upper(text(f, "victimIfsc"));
|
||||
if (!ifsc.isEmpty() && !IFSC.matcher(ifsc).matches()) {
|
||||
throw error("restoration.ifsc", "The IFSC " + ifsc + " is not valid.");
|
||||
}
|
||||
if (!mover.isOurs(ifsc)) {
|
||||
final String channel = upper(text(f, "transferChannel"));
|
||||
if (!I4cRestorationExecutor.CHANNELS.contains(channel.isEmpty() ? "NEFT" : channel)) {
|
||||
throw error("restoration.channel", "Outward channel must be NEFT, IMPS or RTGS.");
|
||||
}
|
||||
} else if (gateway.resolve(accountNo).isEmpty()) {
|
||||
throw error("restoration.victim.account", "Account " + accountNo + " is not in this bank; give the IFSC of the victim's bank.");
|
||||
}
|
||||
if (IN_POOL.equals(r.get("status"))) {
|
||||
// MRM "refer back to victim bank": the victim bank corrected the account; only the destination may change.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET victim_name = ?, victim_account_no = ?, victim_ifsc = ?, victim_bank_name = ?, transfer_channel = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""", trim(text(f, "victimName"), 200), trim(accountNo, 40), ifsc.isEmpty() ? null : ifsc, trim(text(f, "victimBankName"), 200),
|
||||
mover.isOurs(ifsc) ? null : channelOr(f), now(), id);
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET mrm_request_no = ?, victim_name = ?, victim_account_no = ?, victim_ifsc = ?, victim_bank_name = ?,
|
||||
fir_no = ?, fir_date = ?, court_order_no = ?, court_order_date = ?, court_name = ?, bnss_notice_no = ?, bnss_notice_date = ?,
|
||||
indemnity_bond_received = ?, io_name = ?, io_police_station = ?, transfer_channel = ?, status = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", trim(text(f, "mrmRequestNo"), 40), trim(text(f, "victimName"), 200), trim(accountNo, 40), ifsc.isEmpty() ? null : ifsc,
|
||||
trim(text(f, "victimBankName"), 200), trim(text(f, "firNo"), 100), date(f, "firDate"), trim(text(f, "courtOrderNo"), 100),
|
||||
date(f, "courtOrderDate"), trim(text(f, "courtName"), 200), trim(text(f, "bnssNoticeNo"), 100), date(f, "bnssNoticeDate"),
|
||||
Boolean.TRUE.equals(f.get("indemnityBondReceived")), trim(text(f, "ioName"), 200), trim(text(f, "ioPoliceStation"), 200),
|
||||
mover.isOurs(ifsc) ? null : channelOr(f), REQUESTED, now(), id);
|
||||
// SOP: the bank credits the victim within 15 days of receiving the IO's notice / the order.
|
||||
final Date received = date(f, "noticeReceivedOn") != null ? date(f, "noticeReceivedOn")
|
||||
: Date.valueOf(org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate());
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET notice_received_on = ?, compliance_due_on = ? WHERE id = ?", received,
|
||||
Date.valueOf(received.toLocalDate().plusDays(COMPLIANCE_DAYS)), id);
|
||||
}
|
||||
|
||||
/** The suspect contested the refund (SOP: within 15 days of it); the investigation continues and the bank cooperates. */
|
||||
@Transactional
|
||||
public void recordContest(final Long id, final LocalDate contestedOn, final String remarks, final AppUser user) {
|
||||
final Map<String, Object> r = loadBeneficiary(id);
|
||||
if (!RESTORED.equals(r.get("status")) && !CLOSED.equals(r.get("status"))) {
|
||||
throw error("restoration.contest", "Only a restoration already made can be contested.");
|
||||
}
|
||||
if (contestedOn == null || blank(remarks)) {
|
||||
throw error("restoration.contest.details", "Enter the date and the details of the suspect's challenge.");
|
||||
}
|
||||
final Object until = r.get("challenge_until");
|
||||
final boolean late = until != null && contestedOn.isAfter(((Date) until).toLocalDate());
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET contested = true, contested_on = ?, contest_remarks = ?, updated_at = ? WHERE id = ?",
|
||||
Date.valueOf(contestedOn), trim((late ? "[after the 15-day window] " : "") + remarks, 1000), now(), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the five MRM bank actions. COMPLIED checks the IO's papers and raises the RESTORE request (maker); the
|
||||
* others record why the bank did not restore, with the bank statement MRM asks for.
|
||||
*/
|
||||
@Transactional
|
||||
public Long bankAction(final Long id, final String action, final String reason, final AppUser user) {
|
||||
final Map<String, Object> r = loadBeneficiary(id);
|
||||
if (!REQUESTED.equals(r.get("status"))) {
|
||||
throw error("restoration.status", "A bank action needs the victim's MRM request recorded first (status is " + r.get("status") + ").");
|
||||
}
|
||||
final String a = upper(action);
|
||||
if (!BANK_ACTIONS.contains(a)) {
|
||||
throw error("restoration.action", "Bank action must be one of " + BANK_ACTIONS + ".");
|
||||
}
|
||||
final Timestamp now = now();
|
||||
if (!"COMPLIED".equals(a)) {
|
||||
if ("REFER_BACK_LEA".equals(a) && !REFER_BACK_LEA_REASONS.contains(upper(reason))) {
|
||||
throw error("restoration.refer.reason", "Refer back to LEA needs one of " + REFER_BACK_LEA_REASONS + ".");
|
||||
}
|
||||
if (blank(reason)) {
|
||||
throw error("restoration.reason", "Give the reason / remarks MRM asks for.");
|
||||
}
|
||||
if (("NOT_COMPLIED".equals(a) || "REFER_BACK_LEA".equals(a))
|
||||
&& !documents.missing("RESTORATION", id, List.of("BANK_STATEMENT")).isEmpty()) {
|
||||
throw error("restoration.statement", "Attach the account's bank statement (MRM asks for it with this action).");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET bank_action = ?, bank_action_reason = ?, status = ?, updated_at = ? WHERE id = ?", a,
|
||||
trim(reason, 1000), "NOT_COMPLIED".equals(a) ? CLOSED : REQUESTED, now, id);
|
||||
return null;
|
||||
}
|
||||
final Long holdId = ((Number) r.get("hold_id")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) r.get("restorable_amount");
|
||||
if (amount == null || amount.signum() <= 0 || !POSSIBLE.contains(r.get("basis"))) {
|
||||
throw error("restoration.not.possible", "The assessment says restoration is not possible; update it before complying.");
|
||||
}
|
||||
final List<String> problems = complianceProblems(r, documents.missing("RESTORATION", id,
|
||||
List.of("COURT_ORDER", "BNSS_106_NOTICE", "INDEMNITY_BOND", "FIR")), properties.getRestoration().getFirThreshold());
|
||||
if (!problems.isEmpty()) {
|
||||
throw error("restoration.papers", String.join(" ", problems));
|
||||
}
|
||||
final String order = r.get("court_order_no") != null ? "Court order " + r.get("court_order_no") : "BNSS 106 " + r.get("bnss_notice_no");
|
||||
final Long requestId = holdService.requestAction(holdId, I4cHoldService.ACTION_RESTORE, "MRM_RESTORATION",
|
||||
trim(order + " / " + r.get("mrm_request_no"), 200),
|
||||
r.get("court_order_date") != null ? ((Date) r.get("court_order_date")).toLocalDate()
|
||||
: r.get("bnss_notice_date") == null ? null : ((Date) r.get("bnss_notice_date")).toLocalDate(),
|
||||
trim("IO " + nz(r.get("io_name")) + " " + nz(r.get("io_police_station")), 200),
|
||||
trim("Restore " + amount.toPlainString() + " to " + r.get("victim_name") + " a/c " + r.get("victim_account_no")
|
||||
+ (r.get("victim_ifsc") == null ? "" : " " + r.get("victim_ifsc")) + (blank(reason) ? "" : ". " + reason), 1000),
|
||||
amount, id, null, user);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET bank_action = 'COMPLIED', bank_action_reason = ?, status = ?, action_request_id = ?, transfer_error = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""", trim(reason, 1000), AWAITING_APPROVAL, requestId, now, id);
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* What stops compliance, from the MRM manual: either a court order, or the IO's BNSS 106 notice with the victim's
|
||||
* indemnity bond, plus an FIR above the threshold; each with its copy attached.
|
||||
*/
|
||||
static List<String> complianceProblems(final Map<String, Object> r, final List<String> missingDocs, final BigDecimal firThreshold) {
|
||||
final List<String> problems = new ArrayList<>();
|
||||
final boolean court = r.get("court_order_no") != null;
|
||||
if (court) {
|
||||
if (missingDocs.contains("COURT_ORDER")) {
|
||||
problems.add("Attach the court order copy.");
|
||||
}
|
||||
} else {
|
||||
if (r.get("bnss_notice_no") == null) {
|
||||
problems.add("Without a court order, the IO's notice under section 106 BNSS is needed.");
|
||||
} else if (missingDocs.contains("BNSS_106_NOTICE")) {
|
||||
problems.add("Attach the BNSS 106 notice.");
|
||||
}
|
||||
if (!Boolean.TRUE.equals(r.get("indemnity_bond_received"))) {
|
||||
problems.add("The victim's indemnity bond (Rs 10 stamp paper) must be received.");
|
||||
} else if (missingDocs.contains("INDEMNITY_BOND")) {
|
||||
problems.add("Attach the indemnity bond.");
|
||||
}
|
||||
final BigDecimal amount = (BigDecimal) r.get("restorable_amount");
|
||||
if (amount != null && amount.compareTo(firThreshold) > 0) {
|
||||
if (r.get("fir_no") == null) {
|
||||
problems.add("Above Rs " + firThreshold.toPlainString() + " the IO must give the FIR number.");
|
||||
} else if (missingDocs.contains("FIR")) {
|
||||
problems.add("Attach the FIR copy.");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.get("victim_account_no") == null) {
|
||||
problems.add("The victim's refund account is missing.");
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** An outward restoration held in the payable (window closed, switch refused, account corrected): send it again. */
|
||||
public String retryOutward(final Long id) {
|
||||
final Map<String, Object> r = loadBeneficiaryPlain(id);
|
||||
if (!IN_POOL.equals(r.get("status"))) {
|
||||
throw error("restoration.not.pooled", "Only a restoration held in the restoration payable can be sent again.");
|
||||
}
|
||||
try {
|
||||
return executor.sendOutward(id);
|
||||
} catch (final RuntimeException e) {
|
||||
throw error("restoration.outward", "The outward transfer was not sent: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The officer has entered the restoration (amount, transaction id, date) on MRM. */
|
||||
@Transactional
|
||||
public void markMrmUpdated(final Long id, final AppUser user) {
|
||||
final Map<String, Object> r = loadAny(id);
|
||||
final boolean done = RESTORED.equals(r.get("status")) || (VICTIM.equals(r.get("role")) && !CLOSED.equals(r.get("status")))
|
||||
|| "NOT_COMPLIED".equals(r.get("bank_action")) || ASSESSED.equals(r.get("status"));
|
||||
if (!done) {
|
||||
throw error("restoration.mrm", "Nothing to update on MRM yet (status " + r.get("status") + ").");
|
||||
}
|
||||
final boolean close = RESTORED.equals(r.get("status")) || (VICTIM.equals(r.get("role")) && "RECEIVED".equals(r.get("status")));
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET mrm_updated = true, mrm_updated_by = ?, mrm_updated_at = ?, status = CASE WHEN ? THEN ? ELSE status END,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""", name(user), now(), close, CLOSED, now(), id);
|
||||
}
|
||||
|
||||
// ── Victim bank (layer 0: our customer lost the money) ──────────────────
|
||||
|
||||
@Transactional
|
||||
public Long openVictimCase(final String acknowledgementNo, final AppUser user) {
|
||||
final List<Map<String, Object>> req = jdbcTemplate.queryForList(
|
||||
"SELECT id, max_layer, payer_account_number FROM m_i4c_request WHERE acknowledgement_no = ? ORDER BY id DESC LIMIT 1",
|
||||
trim(acknowledgementNo, 30));
|
||||
if (req.isEmpty()) {
|
||||
throw error("restoration.ack", "No I4C complaint with acknowledgement no. " + acknowledgementNo + ".");
|
||||
}
|
||||
if (((Number) req.get(0).get("max_layer")).intValue() != 0) {
|
||||
throw error("restoration.layer", "This complaint is not a layer 0 (victim) complaint of this bank.");
|
||||
}
|
||||
final Integer open = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_restoration WHERE role = ? AND acknowledgement_no = ? AND status <> ?", Integer.class, VICTIM,
|
||||
acknowledgementNo.trim(), CLOSED);
|
||||
if (open != null && open > 0) {
|
||||
throw error("restoration.victim.open", "This complaint already has an open victim-bank case.");
|
||||
}
|
||||
final Timestamp now = now();
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_restoration (role, request_id, acknowledgement_no, victim_account_no, status, created_by, created_at, updated_at)
|
||||
VALUES (?,?,?,?,'AWAITED',?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, VICTIM, req.get(0).get("id"), acknowledgementNo.trim(), req.get(0).get("payer_account_number"), name(user), now,
|
||||
now);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's own claim on the bank: when the transaction happened, when it was reported (to the bank and to
|
||||
* NCRP / 1930), who caused it, and the loss. The RBI framework is chosen by the transaction date (2017 before
|
||||
* 01.01.2027, the 2026 directions after); the shadow-reversal, resolution and compensation follow from it.
|
||||
*/
|
||||
@Transactional
|
||||
public void assessLiability(final Long id, final Map<String, Object> f, final AppUser user) {
|
||||
final Map<String, Object> r = loadVictim(id);
|
||||
final String c = upper(text(f, "category"));
|
||||
final Date txn = date(f, "txnDate");
|
||||
final Date reported = date(f, "reportedOn");
|
||||
if (txn == null || reported == null || !LIABILITY_CATEGORIES.contains(c)) {
|
||||
throw error("restoration.liability", "Enter the transaction date, the date reported and a category of " + LIABILITY_CATEGORIES + ".");
|
||||
}
|
||||
if (reported.before(txn)) {
|
||||
throw error("restoration.liability.dates", "The report cannot be before the transaction.");
|
||||
}
|
||||
final boolean crossBorder = Boolean.TRUE.equals(f.get("crossBorder")) || "true".equalsIgnoreCase(text(f, "crossBorder"));
|
||||
final Date ncrp = date(f, "ncrpReportedOn");
|
||||
final BigDecimal gross = decimalOf(f, "grossLoss");
|
||||
final BigDecimal recovered = decimalOf(f, "recoveredAmount");
|
||||
final AccountRef account = gateway.resolve((String) r.get("victim_account_no")).orElse(null);
|
||||
final String kind = account == null ? "SAVINGS" : account.type();
|
||||
final I4cCustomerLiability.Assessment a = I4cCustomerLiability.assess(c, txn.toLocalDate(), reported.toLocalDate(), crossBorder, kind);
|
||||
final Long clientId = account == null ? null : account.clientId();
|
||||
final Integer previous = clientId == null ? 0 : jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_restoration WHERE victim_client_id = ? AND comp_status = 'PAID' AND id <> ?", Integer.class, clientId,
|
||||
id);
|
||||
final I4cCustomerLiability.Compensation comp = I4cCustomerLiability.compensation(c, txn.toLocalDate(), reported.toLocalDate(),
|
||||
ncrp == null ? null : ncrp.toLocalDate(), gross, recovered, crossBorder, previous != null && previous > 0);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET txn_date = ?, customer_reported_on = ?, liability_category = ?, liability_regime = ?, liability_note = ?,
|
||||
customer_liability = ?, shadow_credit_due_on = ?, resolution_due_on = ?, cross_border = ?, ncrp_reported_on = ?, gross_loss = ?,
|
||||
recovered_amount = ?, victim_client_id = ?, comp_eligible = ?, comp_reason = ?, comp_amount = ?, comp_share_rbi = ?,
|
||||
comp_share_own = ?, comp_share_beneficiary = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", txn, reported, c, a.regime().name(), trim(a.note(), 500), a.limitedCap(),
|
||||
a.shadowReversalDue() == null ? null : Date.valueOf(a.shadowReversalDue()), Date.valueOf(a.resolutionDue()), crossBorder, ncrp,
|
||||
gross, recovered, clientId, comp.eligible(), trim(comp.reason(), 500), comp.amount(), comp.rbi(), comp.ownBank(),
|
||||
comp.beneficiaryBank(), now(), id);
|
||||
}
|
||||
|
||||
/** Para 65.1T(5): the customer applied for compensation; it is paid within 5 calendar days. */
|
||||
@Transactional
|
||||
public void applyCompensation(final Long id, final AppUser user) {
|
||||
final Map<String, Object> r = loadVictim(id);
|
||||
if (!Boolean.TRUE.equals(r.get("comp_eligible")) || r.get("comp_status") != null) {
|
||||
throw error("restoration.comp.apply", "This case is not eligible for compensation, or it was already applied for.");
|
||||
}
|
||||
final LocalDate today = org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate();
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET comp_status = 'APPLIED', comp_applied_on = ?, comp_due_on = ?, comp_applied_by_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", Date.valueOf(today), Date.valueOf(today.plusDays(5)), user.getId(), now(), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checker: pays the compensation to the customer. Dr I4C_COMPENSATION_RECEIVABLE (the RBI and beneficiary-bank
|
||||
* shares, claimed quarterly) and Dr I4C_COMPENSATION_EXPENSE (this bank's share), Cr the account's control.
|
||||
*/
|
||||
@Transactional
|
||||
public void payCompensation(final Long id, final boolean approve, final AppUser checker) {
|
||||
final Map<String, Object> r = loadVictim(id);
|
||||
if (!"APPLIED".equals(r.get("comp_status"))) {
|
||||
throw error("restoration.comp.pay", "No compensation application is waiting.");
|
||||
}
|
||||
if (r.get("comp_applied_by_id") != null && checker.getId().equals(((Number) r.get("comp_applied_by_id")).longValue())) {
|
||||
throw error("restoration.comp.same.user", "Maker-checker: the officer who recorded the application cannot pay it.");
|
||||
}
|
||||
if (!approve) {
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET comp_status = 'REJECTED', updated_at = ? WHERE id = ?", now(), id);
|
||||
return;
|
||||
}
|
||||
final AccountRef customer = gateway.resolve((String) r.get("victim_account_no"))
|
||||
.orElseThrow(() -> error("restoration.victim.account", "Our customer's account " + r.get("victim_account_no") + " was not found."));
|
||||
final BigDecimal recoverable = ((BigDecimal) r.get("comp_share_rbi")).add((BigDecimal) r.get("comp_share_beneficiary"));
|
||||
final I4cMoneyMover.Booked booked = mover.payCompensation(customer, (BigDecimal) r.get("comp_amount"), recoverable,
|
||||
(BigDecimal) r.get("comp_share_own"), "I4C-C" + id);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET comp_status = 'PAID', comp_paid_on = ?, comp_txn_id = ?, comp_journal_ref = ?, updated_at = ? WHERE id = ?
|
||||
""", Date.valueOf(org.apache.fineract.infrastructure.core.service.DateUtils.getBusinessLocalDate()), booked.accountTxnId(),
|
||||
booked.journalRef(), now(), id);
|
||||
}
|
||||
|
||||
/** Para 65.1T(6): the quarter's paid compensations, for the claim to RBI (Annex II(2)) and the beneficiary banks. */
|
||||
public List<Map<String, Object>> compensationClaims(final LocalDate from, final LocalDate to) {
|
||||
return jdbcTemplate.queryForList("""
|
||||
SELECT r.id, r.acknowledgement_no, r.victim_account_no, r.txn_date, r.customer_reported_on, r.ncrp_reported_on, r.gross_loss,
|
||||
r.recovered_amount, r.comp_amount, r.comp_share_rbi, r.comp_share_own, r.comp_share_beneficiary, r.cross_border, r.comp_paid_on
|
||||
FROM m_i4c_restoration r WHERE r.comp_status = 'PAID' AND r.comp_paid_on BETWEEN ? AND ? ORDER BY r.comp_paid_on, r.id
|
||||
""", Date.valueOf(from), Date.valueOf(to));
|
||||
}
|
||||
|
||||
/** The shadow reversal becomes final (zero liability established): the lien on the credited amount is lifted. */
|
||||
@Transactional
|
||||
public void finaliseShadowCredit(final Long id, final AppUser user) {
|
||||
final Map<String, Object> r = loadVictim(id);
|
||||
if (!"CREDITED".equals(r.get("shadow_credit_status")) || r.get("shadow_finalised_at") != null) {
|
||||
throw error("restoration.shadow.final", "There is no provisional shadow reversal to make final.");
|
||||
}
|
||||
releaseShadowLien(r);
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET shadow_finalised_at = ?, updated_at = ? WHERE id = ?", now(), now(), id);
|
||||
}
|
||||
|
||||
private void releaseShadowLien(final Map<String, Object> r) {
|
||||
if (r.get("shadow_hold_txn_id") == null) {
|
||||
return;
|
||||
}
|
||||
final AccountRef customer = gateway.resolve((String) r.get("victim_account_no"))
|
||||
.orElseThrow(() -> error("restoration.victim.account", "Our customer's account was not found."));
|
||||
final Long holdTxn = ((Number) r.get("shadow_hold_txn_id")).longValue();
|
||||
if (gateway.isHoldStillOn(customer, holdTxn)) {
|
||||
gateway.releaseHold(customer, holdTxn);
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal decimalOf(final Map<String, Object> f, final String key) {
|
||||
final String v = text(f, key);
|
||||
if (blank(v)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(v.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw error("restoration.number", key + " must be a number.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Maker: asks for a shadow credit to the customer while the claim is examined. A different officer approves it. */
|
||||
@Transactional
|
||||
public void requestShadowCredit(final Long id, final BigDecimal amount, final AppUser maker) {
|
||||
final Map<String, Object> r = loadVictim(id);
|
||||
if (r.get("liability_category") == null || "CUSTOMER_NEGLIGENCE".equals(r.get("liability_category"))) {
|
||||
throw error("restoration.shadow.category",
|
||||
"A shadow reversal follows a liability assessment that is not customer negligence (that case may get compensation).");
|
||||
}
|
||||
if ("CREDITED".equals(r.get("shadow_credit_status")) || "PENDING".equals(r.get("shadow_credit_status"))) {
|
||||
throw error("restoration.shadow.exists", "A shadow credit is already " + r.get("shadow_credit_status") + ".");
|
||||
}
|
||||
if (amount == null || amount.signum() <= 0) {
|
||||
throw error("restoration.shadow.amount", "Enter the amount to credit.");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET shadow_credit_amount = ?, shadow_credit_status = 'PENDING', shadow_credit_requested_by = ?,
|
||||
shadow_credit_requested_by_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", amount, name(maker), maker.getId(), now(), id);
|
||||
}
|
||||
|
||||
/** Checker: credits the customer from the bank's shadow-credit receivable (Dr I4C_SHADOW_CREDIT, Cr Savings Control). */
|
||||
@Transactional
|
||||
public void approveShadowCredit(final Long id, final boolean approve, final AppUser checker) {
|
||||
final Map<String, Object> r = loadVictim(id);
|
||||
if (!"PENDING".equals(r.get("shadow_credit_status"))) {
|
||||
throw error("restoration.shadow.pending", "No shadow credit is waiting for approval.");
|
||||
}
|
||||
if (checker.getId().equals(r.get("shadow_credit_requested_by_id") == null ? null
|
||||
: ((Number) r.get("shadow_credit_requested_by_id")).longValue())) {
|
||||
throw error("restoration.shadow.same.user", "Maker-checker: you cannot approve a shadow credit you requested.");
|
||||
}
|
||||
if (!approve) {
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET shadow_credit_status = 'REJECTED', updated_at = ? WHERE id = ?", now(), id);
|
||||
return;
|
||||
}
|
||||
final AccountRef customer = gateway.resolve((String) r.get("victim_account_no"))
|
||||
.orElseThrow(() -> error("restoration.victim.account", "Our customer's account " + r.get("victim_account_no") + " was not found."));
|
||||
final I4cMoneyMover.Booked booked = mover.shadowCredit(customer, (BigDecimal) r.get("shadow_credit_amount"), "I4C-V" + id);
|
||||
// A shadow reversal is provisional: the customer cannot use it until the case is decided (RBI 2026, 4(24A)).
|
||||
final Long lien = gateway.placeHold(customer, (BigDecimal) r.get("shadow_credit_amount"), "Shadow reversal I4C-V" + id + " (provisional)");
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET shadow_credit_status = 'CREDITED', shadow_credit_txn_id = ?, shadow_credit_journal_ref = ?,
|
||||
shadow_credited_by = ?, shadow_credited_at = ?, shadow_hold_txn_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", booked.accountTxnId(), booked.journalRef(), name(checker), now(), lien, now(), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restored money arrived for a customer who had a shadow credit: the shadow credit is recovered from the account
|
||||
* (Dr Savings Control, Cr I4C_SHADOW_CREDIT), up to what was received, so the customer is not paid twice.
|
||||
*/
|
||||
@Transactional
|
||||
public void regularise(final Long id, final AppUser user) {
|
||||
final Map<String, Object> r = loadVictim(id);
|
||||
if (!"CREDITED".equals(r.get("shadow_credit_status")) || r.get("regularised_at") != null) {
|
||||
throw error("restoration.regularise", "There is no shadow credit to regularise.");
|
||||
}
|
||||
final BigDecimal received = r.get("received_amount") == null ? BigDecimal.ZERO : (BigDecimal) r.get("received_amount");
|
||||
if (received.signum() <= 0) {
|
||||
throw error("restoration.regularise.received", "Record the restored money received first.");
|
||||
}
|
||||
final BigDecimal amount = received.min((BigDecimal) r.get("shadow_credit_amount"));
|
||||
final AccountRef customer = gateway.resolve((String) r.get("victim_account_no"))
|
||||
.orElseThrow(() -> error("restoration.victim.account", "Our customer's account was not found."));
|
||||
releaseShadowLien(r);
|
||||
final I4cMoneyMover.Booked booked = mover.regulariseShadowCredit(customer, amount, "I4C-V" + id);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET regularised_amount = ?, regularise_journal_ref = ?, regularised_at = ?, updated_at = ? WHERE id = ?
|
||||
""", amount, booked.journalRef(), now(), now(), id);
|
||||
}
|
||||
|
||||
/** {@code days} working days after {@code from}, skipping Saturdays and Sundays (bank holidays are not known here). */
|
||||
static LocalDate addWorkingDays(final LocalDate from, final int days) {
|
||||
return I4cCustomerLiability.addWorkingDays(from, days);
|
||||
}
|
||||
|
||||
/** Restored money credited to our customer: amount, UTR, date and the account it reached. */
|
||||
@Transactional
|
||||
public void recordReceipt(final Long id, final BigDecimal amount, final String utr, final LocalDate receivedOn, final String accountNo,
|
||||
final AppUser user) {
|
||||
final Map<String, Object> r = loadVictim(id);
|
||||
if (amount == null || amount.signum() <= 0 || blank(utr) || receivedOn == null) {
|
||||
throw error("restoration.receipt", "Enter the amount, UTR / transaction id and date of the restored credit.");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_restoration SET received_amount = COALESCE(received_amount, 0) + ?, received_utr = ?, received_on = ?,
|
||||
received_account_no = ?, status = 'RECEIVED', updated_at = ?
|
||||
WHERE id = ?
|
||||
""", amount, trim(utr, 40), Date.valueOf(receivedOn), trim(blank(accountNo) ? (String) r.get("victim_account_no") : accountNo, 40),
|
||||
now(), id);
|
||||
}
|
||||
|
||||
/** Why no (more) restoration is expected for our customer, as LEAs record it on MRM. Closes the case. */
|
||||
@Transactional
|
||||
public void victimStatus(final Long id, final String status, final String remarks, final AppUser user) {
|
||||
loadVictim(id);
|
||||
final String s = upper(status);
|
||||
if (!VICTIM_STATUSES.contains(s)) {
|
||||
throw error("restoration.victim.status", "Victim status must be one of " + VICTIM_STATUSES + ".");
|
||||
}
|
||||
if (blank(remarks)) {
|
||||
throw error("restoration.reason", "Give the justification / remarks.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_restoration SET victim_status = ?, victim_status_remarks = ?, status = ?, updated_at = ? WHERE id = ?", s,
|
||||
trim(remarks, 1000), CLOSED, now(), id);
|
||||
}
|
||||
|
||||
// ── Pro-rata and the MRM balance update ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pro-rata distribution (MRM: when amounts from different complaints are commingled): splits {@code amount} across
|
||||
* the account's active holds in proportion to their disputed amounts. Defaults to what is held on the account.
|
||||
*/
|
||||
public Map<String, Object> proRata(final String accountNo, final BigDecimal amount) {
|
||||
final List<Map<String, Object>> holds = jdbcTemplate.queryForList("""
|
||||
SELECT id, acknowledgement_no, layer, disputed_amount, amount_held FROM m_i4c_hold
|
||||
WHERE account_no = ? AND status = 'ACTIVE' ORDER BY id
|
||||
""", accountNo == null ? "" : accountNo.trim());
|
||||
if (holds.isEmpty()) {
|
||||
throw error("prorata.none", "Account " + accountNo + " has no active I4C holds.");
|
||||
}
|
||||
final BigDecimal totalHeld = holds.stream().map(h -> (BigDecimal) h.get("amount_held")).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
final BigDecimal pot = amount == null ? totalHeld : amount;
|
||||
if (pot.signum() <= 0 || pot.compareTo(totalHeld) > 0) {
|
||||
throw error("prorata.amount", "The amount to distribute must be more than zero and at most the " + totalHeld.toPlainString()
|
||||
+ " held on the account.");
|
||||
}
|
||||
final List<BigDecimal> weights = holds.stream().map(h -> (BigDecimal) h.get("disputed_amount")).toList();
|
||||
final List<BigDecimal> shares = shares(weights, pot);
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (int i = 0; i < holds.size(); i++) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>(holds.get(i));
|
||||
// A share never exceeds what that complaint's hold actually has.
|
||||
row.put("share", shares.get(i).min((BigDecimal) holds.get(i).get("amount_held")));
|
||||
rows.add(row);
|
||||
}
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("accountNo", accountNo);
|
||||
out.put("totalHeld", totalHeld);
|
||||
out.put("amount", pot);
|
||||
out.put("totalDisputed", weights.stream().reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
out.put("holds", rows);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits {@code amount} in proportion to {@code weights}, in paise: each share is rounded down, and the paise left
|
||||
* over go one each to the largest weights, so the shares always add up to the amount exactly.
|
||||
*/
|
||||
static List<BigDecimal> shares(final List<BigDecimal> weights, final BigDecimal amount) {
|
||||
final BigDecimal total = weights.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
final List<BigDecimal> out = new ArrayList<>();
|
||||
if (total.signum() <= 0) {
|
||||
weights.forEach(w -> out.add(BigDecimal.ZERO.setScale(2)));
|
||||
return out;
|
||||
}
|
||||
BigDecimal given = BigDecimal.ZERO;
|
||||
for (final BigDecimal w : weights) {
|
||||
final BigDecimal share = amount.multiply(w).divide(total, 2, RoundingMode.DOWN);
|
||||
out.add(share);
|
||||
given = given.add(share);
|
||||
}
|
||||
int paise = amount.subtract(given).movePointRight(2).intValue();
|
||||
final List<Integer> order = new ArrayList<>();
|
||||
for (int i = 0; i < weights.size(); i++) {
|
||||
order.add(i);
|
||||
}
|
||||
order.sort((a, b) -> weights.get(b).compareTo(weights.get(a)));
|
||||
for (int k = 0; paise > 0 && !order.isEmpty(); k = (k + 1) % order.size(), paise--) {
|
||||
final int i = order.get(k);
|
||||
out.set(i, out.get(i).add(new BigDecimal("0.01")));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MRM "Update Account Balance" sheet: every account with an active I4C hold, its balances now, what is held and
|
||||
* for how many complaints.
|
||||
*/
|
||||
public List<Map<String, Object>> balanceReport() {
|
||||
final List<Map<String, Object>> accounts = jdbcTemplate.queryForList("""
|
||||
SELECT h.account_type, h.account_id, h.account_no, MAX(c.display_name) AS client_name, MAX(o.name) AS office_name,
|
||||
COUNT(*) AS complaints, SUM(h.disputed_amount) AS disputed, SUM(h.amount_held) AS held, SUM(h.shortfall) AS shortfall,
|
||||
STRING_AGG(h.acknowledgement_no, ' ' ORDER BY h.id) AS acknowledgement_nos, BOOL_OR(h.debit_frozen) AS debit_frozen
|
||||
FROM m_i4c_hold h
|
||||
LEFT JOIN m_client c ON c.id = h.client_id
|
||||
LEFT JOIN m_office o ON o.id = h.office_id
|
||||
WHERE h.status = 'ACTIVE'
|
||||
GROUP BY h.account_type, h.account_id, h.account_no
|
||||
ORDER BY h.account_no
|
||||
""");
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> a : accounts) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>(a);
|
||||
gateway.byId((String) a.get("account_type"), ((Number) a.get("account_id")).longValue()).ifPresent((AccountRef ref) -> {
|
||||
row.put("ledger_balance", gateway.ledgerBalance(ref));
|
||||
row.put("available_balance", gateway.withdrawable(ref));
|
||||
row.put("account_status", ref.status() == 300 ? "ACTIVE" : String.valueOf(ref.status()));
|
||||
});
|
||||
out.add(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> loadBeneficiary(final Long id) {
|
||||
final Map<String, Object> r = lock(id);
|
||||
if (!BENEFICIARY.equals(r.get("role"))) {
|
||||
throw error("restoration.role", "This is a victim-bank case, not a restoration from a hold here.");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private Map<String, Object> loadBeneficiaryPlain(final Long id) {
|
||||
final Map<String, Object> r = loadAny(id);
|
||||
if (!BENEFICIARY.equals(r.get("role"))) {
|
||||
throw error("restoration.role", "This is a victim-bank case.");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private Map<String, Object> loadVictim(final Long id) {
|
||||
final Map<String, Object> r = lock(id);
|
||||
if (!VICTIM.equals(r.get("role"))) {
|
||||
throw error("restoration.role", "This is a beneficiary-bank restoration, not a victim-bank case.");
|
||||
}
|
||||
if (CLOSED.equals(r.get("status"))) {
|
||||
throw error("restoration.closed", "This case is closed.");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private Map<String, Object> lock(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_restoration WHERE id = ? FOR UPDATE", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("restoration.not.found", "Restoration " + id + " not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private Map<String, Object> loadAny(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_restoration WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("restoration.not.found", "Restoration " + id + " not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private static String channelOr(final Map<String, Object> f) {
|
||||
final String c = upper(text(f, "transferChannel"));
|
||||
return c.isEmpty() ? "NEFT" : c;
|
||||
}
|
||||
|
||||
private static Date date(final Map<String, Object> f, final String key) {
|
||||
final String v = text(f, key);
|
||||
if (blank(v)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Date.valueOf(LocalDate.parse(v));
|
||||
} catch (final RuntimeException e) {
|
||||
throw error("restoration.date", key + " must be a date in yyyy-MM-dd form.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(final Map<String, Object> f, final String key) {
|
||||
final Object v = f.get(key);
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static String nz(final Object value) {
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
|
||||
|
||||
import java.sql.Date;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* What the MHA NCRP-CFCFRMS SOP (02.01.2026) and RBI's fraud and AML rules ask of the bank around a suspect account,
|
||||
* beyond the hold itself.
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Digital channels</b> suspended on a repeatedly reported account (approved through maker-checker on the hold).
|
||||
* The CBS's own fund transfers refuse a blocked account; channels the CBS does not run (UPI, ATM, cards, AePS) are
|
||||
* recorded for the systems that do.</li>
|
||||
* <li><b>Mule-account register</b>: an account reported in two or more complaints is flagged SUSPECTED automatically;
|
||||
* an officer confirms or clears it.</li>
|
||||
* <li><b>Fraud reporting</b>: police / LEA, RBI fraud monitoring return (default 14 days from classification) and the
|
||||
* FIU-IND suspicious transaction report (default 7 days), with due dates.</li>
|
||||
* <li><b>Linked repayments</b>: fraud money that repaid a loan here after the reported credit, for the officer to hold
|
||||
* and tell the linked account, as the SOP asks.</li>
|
||||
* <li><b>90-day lapse</b>: counted from the bank's grievance; the LEA is told 15 days before, and an extension recorded.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cRiskService {
|
||||
|
||||
/** SOP: digital banking that may be suspended on a repeatedly reported account. */
|
||||
public static final String ALL_DIGITAL = "INTERNAL,IMPS,NEFT,RTGS,UPI,AEPS,ATM,CARD,NETBANKING";
|
||||
public static final int REPEAT_THRESHOLD = 2;
|
||||
public static final int FMR_DAYS = 14;
|
||||
public static final int STR_DAYS = 7;
|
||||
/** UCB Fraud Risk Management Directions, 2026: at least 21 days to answer the show-cause notice. */
|
||||
public static final int SHOW_CAUSE_DAYS = 21;
|
||||
public static final int LAPSE_DAYS = 90;
|
||||
public static final int LEA_NOTICE_DAYS_BEFORE = 15;
|
||||
public static final Set<String> MULE_REVIEW = Set.of("CONFIRMED", "CLEARED");
|
||||
public static final Set<String> ALERT_OUTCOMES = Set.of("ACTIONED", "DISMISSED");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// ── Digital channel suspension (carried out by an approved hold action) ─────────────────────
|
||||
|
||||
/** In the approval's transaction: suspends every digital channel on the hold's account. */
|
||||
public void suspendDigital(final Map<String, Object> hold, final String reason, final String actor) {
|
||||
final Integer active = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_i4c_channel_block WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'", Integer.class,
|
||||
hold.get("account_type"), hold.get("account_id"));
|
||||
if (active == null || active == 0) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_channel_block (hold_id, account_type, account_id, account_no, channels, reason, status, placed_by, placed_at)
|
||||
VALUES (?,?,?,?,?,?,'ACTIVE',?,?)
|
||||
""", hold.get("id"), hold.get("account_type"), hold.get("account_id"), hold.get("account_no"), ALL_DIGITAL, trim(reason, 500),
|
||||
trim(actor, 100), now());
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_hold SET digital_suspended = true WHERE id = ?", hold.get("id"));
|
||||
}
|
||||
|
||||
/** In the approval's transaction: lifts the suspension (GRM "restore digital services", or the hold released). */
|
||||
public void restoreDigital(final Map<String, Object> hold, final String actor) {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_channel_block SET status = 'LIFTED', lifted_by = ?, lifted_at = ?
|
||||
WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'
|
||||
""", trim(actor, 100), now(), hold.get("account_type"), hold.get("account_id"));
|
||||
jdbcTemplate.update("UPDATE m_i4c_hold SET digital_suspended = false WHERE account_type = ? AND account_id = ?", hold.get("account_type"),
|
||||
hold.get("account_id"));
|
||||
}
|
||||
|
||||
// ── Mule-account register ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** After a hold is placed: an account now reported in {@value #REPEAT_THRESHOLD}+ complaints is flagged SUSPECTED. */
|
||||
public void flagIfRepeat(final AccountRef account) {
|
||||
final Integer reports = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(DISTINCT acknowledgement_no) FROM m_i4c_hold WHERE account_type = ? AND account_id = ?", Integer.class,
|
||||
account.type(), account.id());
|
||||
if (reports == null || reports < REPEAT_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
final int updated = jdbcTemplate.update(
|
||||
"UPDATE m_i4c_mule_flag SET reports_count = ? WHERE account_type = ? AND account_id = ? AND status <> 'CLEARED'", reports,
|
||||
account.type(), account.id());
|
||||
if (updated == 0) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_mule_flag (client_id, account_type, account_id, account_no, source, reports_count, reason, status,
|
||||
flagged_by, flagged_at)
|
||||
VALUES (?,?,?,?,'AUTO_REPEAT',?,?,'SUSPECTED','system',?)
|
||||
""", account.clientId(), account.type(), account.id(), account.accountNo(), reports,
|
||||
"Reported in " + reports + " I4C complaints", now());
|
||||
log.info("I4C mule register: {} {} flagged SUSPECTED ({} complaints)", account.type(), account.accountNo(), reports);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Long flagManually(final Long holdId, final String source, final String reason, final AppUser user) {
|
||||
final Map<String, Object> h = hold(holdId);
|
||||
final String src = upper(source).isEmpty() ? "OFFICER" : upper(source);
|
||||
if (!Set.of("OFFICER", "LEA").contains(src)) {
|
||||
throw error("mule.source", "Source must be OFFICER or LEA.");
|
||||
}
|
||||
if (blank(reason)) {
|
||||
throw error("mule.reason", "Give the reason for flagging the account.");
|
||||
}
|
||||
final List<Long> existing = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_i4c_mule_flag WHERE account_type = ? AND account_id = ? AND status <> 'CLEARED'", Long.class,
|
||||
h.get("account_type"), h.get("account_id"));
|
||||
if (!existing.isEmpty()) {
|
||||
return existing.get(0);
|
||||
}
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_mule_flag (client_id, account_type, account_id, account_no, source, reports_count, reason, status, flagged_by,
|
||||
flagged_at)
|
||||
VALUES (?,?,?,?,?,?,?,'SUSPECTED',?,?)
|
||||
RETURNING id
|
||||
""", Long.class, h.get("client_id"), h.get("account_type"), h.get("account_id"), h.get("account_no"), src,
|
||||
reportsOn(h), trim(reason, 1000), name(user), now());
|
||||
}
|
||||
|
||||
/** Officer's review: CONFIRMED (a mule; report it) or CLEARED (not a mule). */
|
||||
@Transactional
|
||||
public void reviewMule(final Long id, final String outcome, final String remarks, final AppUser user) {
|
||||
final String o = upper(outcome);
|
||||
if (!MULE_REVIEW.contains(o)) {
|
||||
throw error("mule.outcome", "Outcome must be CONFIRMED or CLEARED.");
|
||||
}
|
||||
if (blank(remarks)) {
|
||||
throw error("mule.remarks", "Record the basis of the review (EDD, LEA confirmation...).");
|
||||
}
|
||||
final int n = jdbcTemplate.update("""
|
||||
UPDATE m_i4c_mule_flag SET status = ?, reviewed_by = ?, reviewed_at = ?, review_remarks = ? WHERE id = ? AND status <> 'CLEARED'
|
||||
""", o, name(user), now(), trim(remarks, 1000), id);
|
||||
if (n == 0) {
|
||||
throw error("mule.not.found", "Mule flag " + id + " not found or already cleared.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fraud reporting (police, RBI FMR, FIU-IND STR) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opens the fraud case on the hold's account: STR to FIU-IND is due 7 days from the suspicion (PML Rules). Before the
|
||||
* account holder is classified as fraudulent the UCB Fraud Risk Management Directions, 2026 require a show-cause
|
||||
* notice with at least 21 days to respond and a reasoned order; the FMR to RBI is then due within 14 days.
|
||||
*/
|
||||
@Transactional
|
||||
public Long openFraudReport(final Long holdId, final AppUser user) {
|
||||
final Map<String, Object> h = hold(holdId);
|
||||
final List<Long> open = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_i4c_fraud_report WHERE account_no = ? AND status <> 'CLOSED'", Long.class, h.get("account_no"));
|
||||
if (!open.isEmpty()) {
|
||||
return open.get(0);
|
||||
}
|
||||
final Map<String, Object> agg = jdbcTemplate.queryForMap("""
|
||||
SELECT STRING_AGG(DISTINCT acknowledgement_no, ' ') AS acks, COALESCE(SUM(disputed_amount),0) AS amount
|
||||
FROM m_i4c_hold WHERE account_type = ? AND account_id = ?
|
||||
""", h.get("account_type"), h.get("account_id"));
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final List<Long> mule = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_i4c_mule_flag WHERE account_type = ? AND account_id = ? AND status <> 'CLEARED'", Long.class,
|
||||
h.get("account_type"), h.get("account_id"));
|
||||
final Timestamp now = now();
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_fraud_report (hold_id, mule_flag_id, client_id, account_no, acknowledgement_nos, amount, str_due_on, status,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,'SHOW_CAUSE',?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, holdId, mule.isEmpty() ? null : mule.get(0), h.get("client_id"), h.get("account_no"), agg.get("acks"),
|
||||
agg.get("amount"), Date.valueOf(today.plusDays(STR_DAYS)), name(user), now, now);
|
||||
}
|
||||
|
||||
/** The show-cause notice to the account holder: at least 21 days to respond. */
|
||||
@Transactional
|
||||
public void recordShowCause(final Long id, final LocalDate sentOn, final AppUser user) {
|
||||
final Map<String, Object> r = fraudReport(id, "SHOW_CAUSE");
|
||||
if (sentOn == null) {
|
||||
throw error("fraud.show.cause", "Enter the date the show-cause notice was sent.");
|
||||
}
|
||||
if (r.get("classified_on") != null) {
|
||||
throw error("fraud.classified", "Already classified.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_fraud_report SET show_cause_sent_on = ?, show_cause_due_on = ?, updated_at = ? WHERE id = ?",
|
||||
Date.valueOf(sentOn), Date.valueOf(sentOn.plusDays(SHOW_CAUSE_DAYS)), now(), id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void recordShowCauseResponse(final Long id, final LocalDate respondedOn, final String response, final AppUser user) {
|
||||
fraudReport(id, "SHOW_CAUSE");
|
||||
if (respondedOn == null || blank(response)) {
|
||||
throw error("fraud.response", "Enter the date and the account holder's response.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_fraud_report SET show_cause_responded_on = ?, show_cause_response = ?, updated_at = ? WHERE id = ?",
|
||||
Date.valueOf(respondedOn), trim(response, 2000), now(), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasoned order: classified as fraud (FMR due in 14 days), or not (closed). Only after the response, or once the
|
||||
* 21 days have passed without one.
|
||||
*/
|
||||
@Transactional
|
||||
public void decideClassification(final Long id, final boolean fraud, final String orderRef, final LocalDate orderOn, final AppUser user) {
|
||||
final Map<String, Object> r = fraudReport(id, "SHOW_CAUSE");
|
||||
if (r.get("show_cause_sent_on") == null) {
|
||||
throw error("fraud.show.cause.first", "Send the show-cause notice first (21 days to respond).");
|
||||
}
|
||||
final LocalDate due = ((Date) r.get("show_cause_due_on")).toLocalDate();
|
||||
if (r.get("show_cause_responded_on") == null && DateUtils.getBusinessLocalDate().isBefore(due)) {
|
||||
throw error("fraud.show.cause.window", "The account holder has until " + due + " to respond.");
|
||||
}
|
||||
if (orderOn == null || blank(orderRef)) {
|
||||
throw error("fraud.order", "Enter the reasoned order's reference and date.");
|
||||
}
|
||||
if (fraud) {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_fraud_report SET status = 'OPEN', order_ref = ?, order_on = ?, classified_on = ?, fmr_due_on = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", trim(orderRef, 200), Date.valueOf(orderOn), Date.valueOf(orderOn), Date.valueOf(orderOn.plusDays(FMR_DAYS)), now(), id);
|
||||
} else {
|
||||
jdbcTemplate.update("UPDATE m_i4c_fraud_report SET status = 'CLOSED', order_ref = ?, order_on = ?, remarks = ?, updated_at = ? WHERE id = ?",
|
||||
trim(orderRef, 200), Date.valueOf(orderOn), "Not classified as fraud after the show-cause", now(), id);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> fraudReport(final Long id, final String status) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_fraud_report WHERE id = ? FOR UPDATE", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("fraud.report.not.found", "Fraud report " + id + " not found.");
|
||||
}
|
||||
if (!status.equals(rows.get(0).get("status"))) {
|
||||
throw error("fraud.report.status", "Not possible while the case is " + rows.get(0).get("status") + ".");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
/** Records what was filed: FMR (RBI), STR (FIU-IND), police / LEA complaint. Closed when all three are filed. */
|
||||
@Transactional
|
||||
public void updateFraudReport(final Long id, final Map<String, Object> f, final AppUser user) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_fraud_report WHERE id = ? FOR UPDATE", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("fraud.report.not.found", "Fraud report " + id + " not found.");
|
||||
}
|
||||
final Map<String, Object> r = rows.get(0);
|
||||
if (r.get("classified_on") == null && (f.containsKey("fmrRef") && !blank(text(f, "fmrRef")))) {
|
||||
throw error("fraud.fmr.before.classification", "The FMR is filed after the reasoned order classifies the fraud.");
|
||||
}
|
||||
final String fmrRef = pick(f, "fmrRef", r.get("fmr_ref"));
|
||||
final Date fmrOn = pickDate(f, "fmrFiledOn", r.get("fmr_filed_on"));
|
||||
final String strRef = pick(f, "strRef", r.get("str_ref"));
|
||||
final Date strOn = pickDate(f, "strFiledOn", r.get("str_filed_on"));
|
||||
final String policeRef = pick(f, "policeRef", r.get("police_ref"));
|
||||
final Date policeOn = pickDate(f, "policeReportedOn", r.get("police_reported_on"));
|
||||
if ((fmrRef == null) != (fmrOn == null) || (strRef == null) != (strOn == null) || (policeRef == null) != (policeOn == null)) {
|
||||
throw error("fraud.report.pair", "Give each reference with its date (FMR, STR, police).");
|
||||
}
|
||||
final boolean done = fmrRef != null && strRef != null && policeRef != null && r.get("classified_on") != null;
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_i4c_fraud_report SET fmr_ref = ?, fmr_filed_on = ?, str_ref = ?, str_filed_on = ?, police_ref = ?, police_reported_on = ?,
|
||||
remarks = COALESCE(?, remarks), status = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", trim(fmrRef, 100), fmrOn, trim(strRef, 100), strOn, trim(policeRef, 200), policeOn, trim(text(f, "remarks"), 2000),
|
||||
done ? "CLOSED" : (String) r.get("status"), now(), id);
|
||||
}
|
||||
|
||||
// ── Linked repayments ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Looks, for active holds that could not hold the whole disputed amount, for transfers from the held account between
|
||||
* the reported transaction date and the hold to a loan here (fraud money that repaid a loan, SOP 9.1(v)) or to another
|
||||
* savings / current account in this bank (the next layer of the trail is with us). A hold that found the whole
|
||||
* disputed amount raises none: the fraud money did not move on, and later debits are the customer's own money.
|
||||
* Each is raised once as an OPEN alert. Run by the I4C Hold Review job.
|
||||
*/
|
||||
public int scanLinkedRepayments() {
|
||||
return scanLoanRepayments() + scanInternalOnward();
|
||||
}
|
||||
|
||||
private int scanInternalOnward() {
|
||||
return jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_linked_alert (hold_id, kind, target_type, target_id, target_no, amount, transfer_id, moved_on, status, created_at)
|
||||
SELECT h.id, 'INTERNAL_TRANSFER', CASE WHEN d.to_savings_account_id IS NOT NULL THEN 'SAVINGS' ELSE 'CURRENT' END,
|
||||
COALESCE(d.to_savings_account_id, d.to_current_account_id), COALESCE(sa.account_no, ca.account_no), t.amount, t.id,
|
||||
t.transaction_date, 'OPEN', CURRENT_TIMESTAMP
|
||||
FROM m_i4c_hold h
|
||||
JOIN m_i4c_incident i ON i.id = h.incident_id
|
||||
JOIN m_account_transfer_details d
|
||||
ON (d.to_savings_account_id IS NOT NULL OR d.to_current_account_id IS NOT NULL)
|
||||
AND ((h.account_type = 'SAVINGS' AND d.from_savings_account_id = h.account_id)
|
||||
OR (h.account_type = 'CURRENT' AND d.from_current_account_id = h.account_id))
|
||||
JOIN m_account_transfer_transaction t ON t.account_transfer_details_id = d.id AND t.is_reversed = false
|
||||
LEFT JOIN m_savings_account sa ON sa.id = d.to_savings_account_id
|
||||
LEFT JOIN m_current_account ca ON ca.id = d.to_current_account_id
|
||||
WHERE h.status = 'ACTIVE' AND h.layer >= 1 AND COALESCE(i.outcome, '') <> 'HELD'
|
||||
AND t.transaction_date BETWEEN COALESCE(i.transaction_date, CAST(h.held_at AS DATE)) AND CAST(h.held_at AS DATE)
|
||||
AND NOT EXISTS (SELECT 1 FROM m_i4c_linked_alert a WHERE a.hold_id = h.id AND a.transfer_id = t.id)
|
||||
""");
|
||||
}
|
||||
|
||||
private int scanLoanRepayments() {
|
||||
return jdbcTemplate.update("""
|
||||
INSERT INTO m_i4c_linked_alert (hold_id, kind, target_type, target_id, target_no, amount, transfer_id, moved_on, status, created_at)
|
||||
SELECT h.id, 'LOAN_REPAYMENT', 'LOAN', d.to_loan_account_id, l.account_no, t.amount, t.id, t.transaction_date, 'OPEN', CURRENT_TIMESTAMP
|
||||
FROM m_i4c_hold h
|
||||
JOIN m_i4c_incident i ON i.id = h.incident_id
|
||||
JOIN m_account_transfer_details d
|
||||
ON d.to_loan_account_id IS NOT NULL
|
||||
AND ((h.account_type = 'SAVINGS' AND d.from_savings_account_id = h.account_id)
|
||||
OR (h.account_type = 'CURRENT' AND d.from_current_account_id = h.account_id))
|
||||
JOIN m_account_transfer_transaction t ON t.account_transfer_details_id = d.id AND t.is_reversed = false
|
||||
JOIN m_loan l ON l.id = d.to_loan_account_id
|
||||
WHERE h.status = 'ACTIVE' AND h.layer >= 1 AND COALESCE(i.outcome, '') <> 'HELD'
|
||||
AND t.transaction_date BETWEEN COALESCE(i.transaction_date, CAST(h.held_at AS DATE)) AND CAST(h.held_at AS DATE)
|
||||
AND NOT EXISTS (SELECT 1 FROM m_i4c_linked_alert a WHERE a.hold_id = h.id AND a.transfer_id = t.id)
|
||||
""");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void actionAlert(final Long id, final String outcome, final String remarks, final AppUser user) {
|
||||
final String o = upper(outcome);
|
||||
if (!ALERT_OUTCOMES.contains(o)) {
|
||||
throw error("alert.outcome", "Outcome must be ACTIONED or DISMISSED.");
|
||||
}
|
||||
if (blank(remarks)) {
|
||||
throw error("alert.remarks", "Say what was done (amount held on the loan, linked account told, or why dismissed).");
|
||||
}
|
||||
final int n = jdbcTemplate.update("""
|
||||
UPDATE m_i4c_linked_alert SET status = ?, remarks = ?, actioned_by = ?, actioned_at = ? WHERE id = ? AND status = 'OPEN'
|
||||
""", o, trim(remarks, 1000), name(user), now(), id);
|
||||
if (n == 0) {
|
||||
throw error("alert.not.open", "Alert " + id + " is not open.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 90-day lapse (SOP: from the bank's grievance; LEA told 15 days before) ─────────────────
|
||||
|
||||
/** Starts the lapse clock when the bank raises the account holder's grievance. */
|
||||
public void startLapseClock(final Long holdId, final LocalDate from) {
|
||||
final LocalDate lapse = from.plusDays(LAPSE_DAYS);
|
||||
jdbcTemplate.update("UPDATE m_i4c_hold SET lapse_on = COALESCE(lapse_on, ?), lea_notice_due_on = COALESCE(lea_notice_due_on, ?) WHERE id = ?",
|
||||
Date.valueOf(lapse), Date.valueOf(lapse.minusDays(LEA_NOTICE_DAYS_BEFORE)), holdId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void recordLeaNotice(final Long holdId, final LocalDate sentOn, final String reference, final AppUser user) {
|
||||
hold(holdId);
|
||||
if (sentOn == null || blank(reference)) {
|
||||
throw error("lea.notice", "Enter the date and reference of the notice to the LEA.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_hold SET lea_notice_sent_on = ?, lea_notice_ref = ? WHERE id = ?", Date.valueOf(sentOn),
|
||||
trim(reference, 200), holdId);
|
||||
}
|
||||
|
||||
/** The LEA extended the hold beyond the lapse date (a lawful direction). */
|
||||
@Transactional
|
||||
public void recordExtension(final Long holdId, final LocalDate until, final String reference, final AppUser user) {
|
||||
hold(holdId);
|
||||
if (until == null || blank(reference)) {
|
||||
throw error("lea.extension", "Enter the extended date and the LEA's order reference.");
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_i4c_hold SET extension_until = ?, extension_ref = ? WHERE id = ?", Date.valueOf(until),
|
||||
trim(reference, 200), holdId);
|
||||
}
|
||||
|
||||
/** True when the hold may be released without an order: its review or lapse date passed with no extension in force. */
|
||||
public static boolean lapsed(final Map<String, Object> hold, final LocalDate today) {
|
||||
final Object extension = hold.get("extension_until");
|
||||
if (extension != null && !toLocal(extension).isBefore(today)) {
|
||||
return false;
|
||||
}
|
||||
final Object lapse = hold.get("lapse_on");
|
||||
final Object review = hold.get("review_due_on");
|
||||
return (lapse != null && !toLocal(lapse).isAfter(today)) || (review != null && !toLocal(review).isAfter(today));
|
||||
}
|
||||
|
||||
// ── Nodal / pool / escrow accounts excluded from I4C holds ───────────────────────────────
|
||||
|
||||
public static final Set<String> EXCLUSION_KINDS = Set.of("NODAL", "POOL", "ESCROW");
|
||||
|
||||
@Transactional
|
||||
public Long addExclusion(final AccountRef account, final String kind, final String reason, final AppUser user) {
|
||||
final String k = upper(kind);
|
||||
if (!EXCLUSION_KINDS.contains(k)) {
|
||||
throw error("exclusion.kind", "Kind must be NODAL, POOL or ESCROW.");
|
||||
}
|
||||
if (blank(reason)) {
|
||||
throw error("exclusion.reason", "Say what the account is (merchant nodal account, payment aggregator pool...).");
|
||||
}
|
||||
final List<Long> existing = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM m_i4c_hold_exclusion WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'", Long.class, account.type(),
|
||||
account.id());
|
||||
if (!existing.isEmpty()) {
|
||||
throw error("exclusion.exists", "Account " + account.accountNo() + " is already excluded.");
|
||||
}
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_hold_exclusion (account_type, account_id, account_no, kind, reason, status, added_by, added_at)
|
||||
VALUES (?,?,?,?,?,'ACTIVE',?,?)
|
||||
RETURNING id
|
||||
""", Long.class, account.type(), account.id(), account.accountNo(), k, trim(reason, 500), name(user), now());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeExclusion(final Long id, final AppUser user) {
|
||||
final int n = jdbcTemplate.update("""
|
||||
UPDATE m_i4c_hold_exclusion SET status = 'REMOVED', removed_by = ?, removed_at = ? WHERE id = ? AND status = 'ACTIVE'
|
||||
""", name(user), now(), id);
|
||||
if (n == 0) {
|
||||
throw error("exclusion.not.found", "Exclusion " + id + " not found or already removed.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── IFSC -> I4C Bank Code map ──────────────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public Long mapIfsc(final String ifscKey, final Integer bankCode, final String note, final AppUser user) {
|
||||
final String key = upper(ifscKey);
|
||||
if (!key.matches("^[A-Z]{4}$") && !key.matches("^[A-Z]{4}0[A-Z0-9]{6}$")) {
|
||||
throw error("ifsc.key", "Give a 4-letter IFSC prefix or a full 11-character IFSC.");
|
||||
}
|
||||
final Integer known = bankCode == null ? 0
|
||||
: jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_i4c_bank_master WHERE bank_code = ?", Integer.class, bankCode);
|
||||
if (known == null || known == 0) {
|
||||
throw error("ifsc.bank.code", "Bank code " + bankCode + " is not in the I4C Bank Code Master.");
|
||||
}
|
||||
jdbcTemplate.update("DELETE FROM m_i4c_ifsc_map WHERE ifsc_key = ?", key);
|
||||
return jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_i4c_ifsc_map (ifsc_key, bank_code, note, added_by, added_at) VALUES (?,?,?,?,?) RETURNING id
|
||||
""", Long.class, key, bankCode, trim(note, 300), name(user), now());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void unmapIfsc(final Long id) {
|
||||
jdbcTemplate.update("DELETE FROM m_i4c_ifsc_map WHERE id = ?", id);
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private Map<String, Object> hold(final Long holdId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", holdId);
|
||||
if (rows.isEmpty()) {
|
||||
throw error("hold.not.found", "I4C hold " + holdId + " not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private Integer reportsOn(final Map<String, Object> h) {
|
||||
return jdbcTemplate.queryForObject("SELECT COUNT(DISTINCT acknowledgement_no) FROM m_i4c_hold WHERE account_type = ? AND account_id = ?",
|
||||
Integer.class, h.get("account_type"), h.get("account_id"));
|
||||
}
|
||||
|
||||
private static LocalDate toLocal(final Object value) {
|
||||
return value instanceof Date d ? d.toLocalDate() : LocalDate.parse(String.valueOf(value));
|
||||
}
|
||||
|
||||
private static String pick(final Map<String, Object> f, final String key, final Object current) {
|
||||
if (f.containsKey(key)) {
|
||||
final String v = text(f, key);
|
||||
return blank(v) ? null : v.trim();
|
||||
}
|
||||
return current == null ? null : String.valueOf(current);
|
||||
}
|
||||
|
||||
private static Date pickDate(final Map<String, Object> f, final String key, final Object current) {
|
||||
if (f.containsKey(key)) {
|
||||
final String v = text(f, key);
|
||||
if (blank(v)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Date.valueOf(LocalDate.parse(v.trim()));
|
||||
} catch (final RuntimeException e) {
|
||||
throw error("date.invalid", key + " must be a date in yyyy-MM-dd form.", e);
|
||||
}
|
||||
}
|
||||
return (Date) current;
|
||||
}
|
||||
|
||||
private static String text(final Map<String, Object> f, final String key) {
|
||||
final Object v = f.get(key);
|
||||
return v == null ? null : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static Timestamp now() {
|
||||
return Timestamp.from(Instant.now());
|
||||
}
|
||||
|
||||
private static String name(final AppUser user) {
|
||||
return user == null ? "system" : trim(user.getDisplayName(), 100);
|
||||
}
|
||||
|
||||
private static boolean blank(final String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String upper(final String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String trim(final String value, final int max) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String t = value.trim();
|
||||
return t.length() <= max ? t : t.substring(0, max);
|
||||
}
|
||||
|
||||
private static PlatformDataIntegrityException error(final String code, final String message, final Object... cause) {
|
||||
return new PlatformDataIntegrityException("error.msg.i4c." + code, message, cause);
|
||||
}
|
||||
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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.util.function.Supplier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.core.domain.ActionContext;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Runs I4C work as the CBS "system" user.
|
||||
*
|
||||
* I4C's own calls arrive with no Fineract login, and the account write services read the signed-in user for their audit
|
||||
* columns ({@code context.authenticatedUser()}), so the work is done as the built-in system user, the same identity
|
||||
* scheduled jobs use (see JobStarter). Everything placed for I4C is then visibly the system's doing, never an
|
||||
* officer's.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class I4cSystemContext {
|
||||
|
||||
private final AppUserRepositoryWrapper userRepository;
|
||||
private final TenantDetailsService tenantDetailsService;
|
||||
private final BusinessDateReadPlatformService businessDateReadPlatformService;
|
||||
|
||||
/**
|
||||
* Runs {@code work} as the system user in the tenant already on this thread, then puts the caller's identity back.
|
||||
*/
|
||||
public <T> T callAsSystem(final Supplier<T> work) {
|
||||
final SecurityContext previous = SecurityContextHolder.getContext();
|
||||
try {
|
||||
final AppUser user = userRepository.fetchSystemUser();
|
||||
final SecurityContext systemContext = SecurityContextHolder.createEmptyContext();
|
||||
systemContext.setAuthentication(new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()));
|
||||
SecurityContextHolder.setContext(systemContext);
|
||||
return work.get();
|
||||
} finally {
|
||||
SecurityContextHolder.setContext(previous);
|
||||
}
|
||||
}
|
||||
|
||||
public void runAsSystem(final Runnable work) {
|
||||
callAsSystem(() -> {
|
||||
work.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For a thread with no context at all (a background worker): sets the tenant, its business dates and the system
|
||||
* user, runs {@code work}, and clears everything again so a pooled thread never carries one tenant into the next
|
||||
* task.
|
||||
*/
|
||||
public void runInTenant(final FineractPlatformTenant tenant, final Runnable work) {
|
||||
try {
|
||||
ThreadLocalContextUtil.setTenant(tenant);
|
||||
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
|
||||
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
|
||||
runAsSystem(work);
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext();
|
||||
ThreadLocalContextUtil.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public FineractPlatformTenant loadTenant(final String tenantIdentifier) {
|
||||
return tenantDetailsService.loadTenantById(tenantIdentifier);
|
||||
}
|
||||
}
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 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.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Finds a reported transaction in this bank's books and follows the money out of the account.
|
||||
*
|
||||
* What this CBS can and cannot see decides how:
|
||||
* <ul>
|
||||
* <li>Account transactions carry no RRN/UTR. The only RRNs in the books are on outward fund transfers
|
||||
* ({@code m_fund_transfer.utr / txn_ref / switch_txn_id}); otherwise an RRN can only appear as free text in a
|
||||
* transaction note or transfer description. So a reported transaction is found by RRN where possible, and otherwise by
|
||||
* account, exact amount and a date window. The method used is recorded, so an officer can see which matches rest on
|
||||
* amount and date alone.</li>
|
||||
* <li>Money leaves an account here by internal transfer, by outward IMPS/NEFT/RTGS (with payee IFSC, account and UTR),
|
||||
* or by a counter withdrawal (with or without a cheque). There is no UPI, ATM, POS or AEPS channel in this CBS, so the
|
||||
* specification's ATM/POS/AEPS withdrawal types never arise.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class I4cTransactionTracer {
|
||||
|
||||
static final int SAVINGS_DEPOSIT = 1;
|
||||
static final int SAVINGS_WITHDRAWAL = 2;
|
||||
static final int CURRENT_DEPOSIT = 1;
|
||||
static final int CURRENT_WITHDRAWAL = 2;
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final I4cProperties properties;
|
||||
|
||||
/** A transaction of ours matched to a reported incident. */
|
||||
public record MatchedTxn(Long txnId, LocalDate date, BigDecimal amount, String dateTime, String method) {
|
||||
}
|
||||
|
||||
public enum MovementKind {
|
||||
OUTWARD_TRANSFER, INTERNAL_TRANSFER, CHEQUE_WITHDRAWAL, CASH_WITHDRAWAL, OTHER_DEBIT,
|
||||
/** Card and Aadhaar channels: this CBS has none today; a channel that records them fills {@code details}. */
|
||||
ATM_WITHDRAWAL, POS_WITHDRAWAL, AEPS_WITHDRAWAL
|
||||
}
|
||||
|
||||
/** One debit that took money out of the account, with whatever this CBS knows about where it went. */
|
||||
/**
|
||||
* {@code details} carries what a card or AEPS channel knows and the response needs: atm_id, place_of_atm, atm_of_bank
|
||||
* (ATM); mid, tid, approval_code, merchant_name, pos_transaction_id (POS).
|
||||
*/
|
||||
public record Movement(MovementKind kind, Long txnId, BigDecimal amount, String dateTime, String reference, String payeeIfsc,
|
||||
String payeeAccountNo, String payeeName, String channel, String chequeNo, Long payeeOfficeId, Map<String, String> details) {
|
||||
|
||||
public Movement(final MovementKind kind, final Long txnId, final BigDecimal amount, final String dateTime, final String reference,
|
||||
final String payeeIfsc, final String payeeAccountNo, final String payeeName, final String channel, final String chequeNo,
|
||||
final Long payeeOfficeId) {
|
||||
this(kind, txnId, amount, dateTime, reference, payeeIfsc, payeeAccountNo, payeeName, channel, chequeNo, payeeOfficeId, Map.of());
|
||||
}
|
||||
|
||||
public String detail(final String key) {
|
||||
return details == null ? null : details.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
public enum Direction {
|
||||
DEBIT, CREDIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the incident on this account. {@code exclude} holds transactions already matched to other incidents of
|
||||
* the same complaint, so two incidents of the same amount do not both claim one transaction.
|
||||
*/
|
||||
public Optional<MatchedTxn> match(final AccountRef account, final String rrn, final BigDecimal amount, final LocalDate date,
|
||||
final Direction direction, final Collection<Long> exclude) {
|
||||
if (direction == Direction.DEBIT && account.isSavings()) {
|
||||
final Optional<MatchedTxn> byTransfer = matchOutwardTransferByRrn(account, rrn, exclude);
|
||||
if (byTransfer.isPresent()) {
|
||||
return byTransfer;
|
||||
}
|
||||
}
|
||||
final Optional<MatchedTxn> byNote = matchByReferenceText(account, rrn, direction, exclude);
|
||||
if (byNote.isPresent()) {
|
||||
return byNote;
|
||||
}
|
||||
return matchByAmountAndDate(account, amount, date, direction, exclude);
|
||||
}
|
||||
|
||||
/** Debits after {@code afterTxnId} (or from {@code fromDate} when nothing was matched), oldest first. */
|
||||
public List<Movement> debitsAfter(final AccountRef account, final Long afterTxnId, final LocalDate fromDate, final int limit) {
|
||||
final List<Map<String, Object>> rows;
|
||||
if (account.isSavings()) {
|
||||
rows = jdbcTemplate.queryForList("""
|
||||
SELECT t.id, t.amount, t.transaction_date, t.created_on_utc AS created_at
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ? AND t.transaction_type_enum = ? AND t.is_reversed = false
|
||||
AND (CAST(? AS BIGINT) IS NULL OR t.id > ?) AND t.transaction_date >= ?
|
||||
ORDER BY t.transaction_date, t.id
|
||||
LIMIT ?
|
||||
""", account.id(), SAVINGS_WITHDRAWAL, afterTxnId, afterTxnId, Date.valueOf(fromDate), limit);
|
||||
} else {
|
||||
rows = jdbcTemplate.queryForList("""
|
||||
SELECT t.id, t.amount, t.transaction_date, t.created_date AS created_at
|
||||
FROM m_current_account_transaction t
|
||||
WHERE t.current_account_id = ? AND t.transaction_type_enum = ? AND t.is_reversed = false
|
||||
AND (CAST(? AS BIGINT) IS NULL OR t.id > ?) AND t.transaction_date >= ?
|
||||
ORDER BY t.transaction_date, t.id
|
||||
LIMIT ?
|
||||
""", account.id(), CURRENT_WITHDRAWAL, afterTxnId, afterTxnId, Date.valueOf(fromDate), limit);
|
||||
}
|
||||
final List<Movement> movements = new ArrayList<>();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
movements.add(classify(account, row));
|
||||
}
|
||||
return movements;
|
||||
}
|
||||
|
||||
/** Classifies one debit we already know the id of (used for the matched layer-0 debit). */
|
||||
public Movement describeDebit(final AccountRef account, final MatchedTxn txn) {
|
||||
final List<Map<String, Object>> rows = account.isSavings()
|
||||
? jdbcTemplate.queryForList("SELECT t.id, t.amount, t.transaction_date, t.created_on_utc AS created_at"
|
||||
+ " FROM m_savings_account_transaction t WHERE t.id = ?", txn.txnId())
|
||||
: jdbcTemplate.queryForList("SELECT t.id, t.amount, t.transaction_date, t.created_date AS created_at"
|
||||
+ " FROM m_current_account_transaction t WHERE t.id = ?", txn.txnId());
|
||||
if (rows.isEmpty()) {
|
||||
return new Movement(MovementKind.OTHER_DEBIT, txn.txnId(), txn.amount(), txn.dateTime(), null, null, null, null, null, null,
|
||||
null);
|
||||
}
|
||||
return classify(account, rows.get(0));
|
||||
}
|
||||
|
||||
private Movement classify(final AccountRef account, final Map<String, Object> row) {
|
||||
final Long txnId = ((Number) row.get("id")).longValue();
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
final String dateTime = dateTime(row.get("transaction_date"), row.get("created_at"));
|
||||
|
||||
if (account.isSavings()) {
|
||||
// Transfer out of this savings account: the transfer details say where it went; an outward
|
||||
// IMPS/NEFT/RTGS is the transfer whose details row is m_fund_transfer.internal_txn_id.
|
||||
final List<Map<String, Object>> transfer = jdbcTemplate.queryForList("""
|
||||
SELECT d.id AS details_id, d.to_savings_account_id, d.to_current_account_id, d.to_office_id,
|
||||
ts.account_no AS to_savings_no, tc.account_no AS to_current_no,
|
||||
ft.channel, ft.payee_ifsc, ft.payee_account_number, ft.payee_name, ft.utr, ft.txn_ref, ft.direction
|
||||
FROM m_account_transfer_transaction att
|
||||
JOIN m_account_transfer_details d ON d.id = att.account_transfer_details_id
|
||||
LEFT JOIN m_savings_account ts ON ts.id = d.to_savings_account_id
|
||||
LEFT JOIN m_current_account tc ON tc.id = d.to_current_account_id
|
||||
LEFT JOIN m_fund_transfer ft ON ft.internal_txn_id = d.id
|
||||
WHERE att.from_savings_transaction_id = ? AND att.is_reversed = false
|
||||
ORDER BY att.id
|
||||
LIMIT 1
|
||||
""", txnId);
|
||||
if (!transfer.isEmpty()) {
|
||||
final Map<String, Object> t = transfer.get(0);
|
||||
if (t.get("payee_ifsc") != null && "OFF_US".equalsIgnoreCase(String.valueOf(t.get("direction")))) {
|
||||
final String utr = firstNonBlank((String) t.get("utr"), (String) t.get("txn_ref"));
|
||||
return new Movement(MovementKind.OUTWARD_TRANSFER, txnId, amount, dateTime, utr, (String) t.get("payee_ifsc"),
|
||||
(String) t.get("payee_account_number"), (String) t.get("payee_name"), (String) t.get("channel"), null, null);
|
||||
}
|
||||
final String toAccount = firstNonBlank((String) t.get("to_savings_no"), (String) t.get("to_current_no"),
|
||||
(String) t.get("payee_account_number"));
|
||||
final Long toOffice = t.get("to_office_id") instanceof Number n ? n.longValue() : null;
|
||||
return new Movement(MovementKind.INTERNAL_TRANSFER, txnId, amount, dateTime,
|
||||
firstNonBlank((String) t.get("txn_ref"), "TRF" + t.get("details_id")), null, toAccount, null, "INTERNAL", null,
|
||||
toOffice);
|
||||
}
|
||||
}
|
||||
|
||||
// Counter withdrawal raised through the clerk/cashier register: the cheque number lives on the request row.
|
||||
final List<Map<String, Object>> clerk = jdbcTemplate.queryForList("""
|
||||
SELECT r.cheque_no, r.id FROM m_clerk_withdrawal_request r
|
||||
WHERE r.txn_id = ? AND r.account_no = ?
|
||||
ORDER BY r.id DESC
|
||||
LIMIT 1
|
||||
""", String.valueOf(txnId), account.accountNo());
|
||||
if (!clerk.isEmpty()) {
|
||||
final String cheque = (String) clerk.get(0).get("cheque_no");
|
||||
if (cheque != null && !cheque.isBlank()) {
|
||||
return new Movement(MovementKind.CHEQUE_WITHDRAWAL, txnId, amount, dateTime, "WDR" + clerk.get(0).get("id"), null, null,
|
||||
null, "BRANCH", cheque.trim(), null);
|
||||
}
|
||||
return new Movement(MovementKind.CASH_WITHDRAWAL, txnId, amount, dateTime, "WDR" + clerk.get(0).get("id"), null, null, null,
|
||||
"BRANCH", null, null);
|
||||
}
|
||||
// A cheque paid through CTS inward clearing: linked by account, cheque amount and date, as the item row keeps
|
||||
// no transaction id.
|
||||
if (account.isSavings()) {
|
||||
final List<Map<String, Object>> cts = jdbcTemplate.queryForList("""
|
||||
SELECT i.cheque_number FROM m_inward_clearing_item i
|
||||
WHERE i.account_id = ? AND i.amount = ? AND i.decision = 'PASS'
|
||||
ORDER BY i.id DESC
|
||||
LIMIT 1
|
||||
""", account.id(), amount);
|
||||
if (!cts.isEmpty() && cts.get(0).get("cheque_number") != null) {
|
||||
return new Movement(MovementKind.CHEQUE_WITHDRAWAL, txnId, amount, dateTime, null, null, null, null, "CTS",
|
||||
String.valueOf(cts.get(0).get("cheque_number")), null);
|
||||
}
|
||||
}
|
||||
return new Movement(MovementKind.OTHER_DEBIT, txnId, amount, dateTime, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private Optional<MatchedTxn> matchOutwardTransferByRrn(final AccountRef account, final String rrn, final Collection<Long> exclude) {
|
||||
if (rrn == null || rrn.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT t.id, t.amount, t.transaction_date, t.created_on_utc AS created_at
|
||||
FROM m_fund_transfer ft
|
||||
JOIN m_account_transfer_transaction att ON att.account_transfer_details_id = ft.internal_txn_id
|
||||
JOIN m_savings_account_transaction t ON t.id = att.from_savings_transaction_id
|
||||
WHERE (ft.utr = ? OR ft.txn_ref = ? OR ft.switch_txn_id = ?) AND t.savings_account_id = ? AND t.is_reversed = false
|
||||
ORDER BY t.id
|
||||
""", rrn, rrn, rrn, account.id());
|
||||
return first(rows, exclude, "RRN_FUND_TRANSFER");
|
||||
}
|
||||
|
||||
/** RRN quoted in a transaction note or transfer description - the only place an inward reference can be. */
|
||||
private Optional<MatchedTxn> matchByReferenceText(final AccountRef account, final String rrn, final Direction direction,
|
||||
final Collection<Long> exclude) {
|
||||
if (rrn == null || rrn.length() < 6 || !account.isSavings()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
final int type = direction == Direction.DEBIT ? SAVINGS_WITHDRAWAL : SAVINGS_DEPOSIT;
|
||||
final String like = "%" + rrn.replace("%", "").replace("_", "") + "%";
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT DISTINCT t.id, t.amount, t.transaction_date, t.created_on_utc AS created_at
|
||||
FROM m_savings_account_transaction t
|
||||
LEFT JOIN m_note n ON n.savings_account_transaction_id = t.id
|
||||
LEFT JOIN m_account_transfer_transaction att
|
||||
ON att.from_savings_transaction_id = t.id OR att.to_savings_transaction_id = t.id
|
||||
WHERE t.savings_account_id = ? AND t.transaction_type_enum = ? AND t.is_reversed = false
|
||||
AND (n.note LIKE ? OR att.description LIKE ?)
|
||||
ORDER BY t.id
|
||||
""", account.id(), type, like, like);
|
||||
return first(rows, exclude, "RRN_REFERENCE_TEXT");
|
||||
}
|
||||
|
||||
private Optional<MatchedTxn> matchByAmountAndDate(final AccountRef account, final BigDecimal amount, final LocalDate date,
|
||||
final Direction direction, final Collection<Long> exclude) {
|
||||
if (amount == null || date == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
final int window = Math.max(0, properties.getMatch().getDateWindowDays());
|
||||
final Date from = Date.valueOf(date.minusDays(window));
|
||||
final Date to = Date.valueOf(date.plusDays(window));
|
||||
final List<Map<String, Object>> rows;
|
||||
if (account.isSavings()) {
|
||||
rows = jdbcTemplate.queryForList("""
|
||||
SELECT t.id, t.amount, t.transaction_date, t.created_on_utc AS created_at
|
||||
FROM m_savings_account_transaction t
|
||||
WHERE t.savings_account_id = ? AND t.transaction_type_enum = ? AND t.is_reversed = false
|
||||
AND t.amount = ? AND t.transaction_date BETWEEN ? AND ?
|
||||
ORDER BY ABS(t.transaction_date - CAST(? AS DATE)), t.id
|
||||
""", account.id(), direction == Direction.DEBIT ? SAVINGS_WITHDRAWAL : SAVINGS_DEPOSIT, amount, from, to,
|
||||
Date.valueOf(date));
|
||||
} else {
|
||||
rows = jdbcTemplate.queryForList("""
|
||||
SELECT t.id, t.amount, t.transaction_date, t.created_date AS created_at
|
||||
FROM m_current_account_transaction t
|
||||
WHERE t.current_account_id = ? AND t.transaction_type_enum = ? AND t.is_reversed = false
|
||||
AND t.amount = ? AND t.transaction_date BETWEEN ? AND ?
|
||||
ORDER BY ABS(t.transaction_date - CAST(? AS DATE)), t.id
|
||||
""", account.id(), direction == Direction.DEBIT ? CURRENT_WITHDRAWAL : CURRENT_DEPOSIT, amount, from, to,
|
||||
Date.valueOf(date));
|
||||
}
|
||||
return first(rows, exclude, "AMOUNT_DATE");
|
||||
}
|
||||
|
||||
private Optional<MatchedTxn> first(final List<Map<String, Object>> rows, final Collection<Long> exclude, final String method) {
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Long id = ((Number) r.get("id")).longValue();
|
||||
if (exclude.contains(id)) {
|
||||
continue;
|
||||
}
|
||||
final LocalDate date = toLocalDate(r.get("transaction_date"));
|
||||
return Optional.of(new MatchedTxn(id, date, (BigDecimal) r.get("amount"),
|
||||
dateTime(r.get("transaction_date"), r.get("created_at")), method));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* "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 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();
|
||||
return (date == null ? local : date.atTime(local.toLocalTime())).withNano(0).format(DATE_TIME);
|
||||
}
|
||||
return date == null ? "" : date.atStartOfDay().format(DATE_TIME);
|
||||
}
|
||||
|
||||
private static java.time.ZoneId tenantZone() {
|
||||
try {
|
||||
return DateUtils.getDateTimeZoneOfTenant();
|
||||
} catch (final RuntimeException e) {
|
||||
return java.time.ZoneId.of("Asia/Kolkata");
|
||||
}
|
||||
}
|
||||
|
||||
static LocalDate toLocalDate(final Object value) {
|
||||
if (value instanceof Date d) {
|
||||
return d.toLocalDate();
|
||||
}
|
||||
if (value instanceof LocalDate d) {
|
||||
return d;
|
||||
}
|
||||
if (value instanceof Timestamp ts) {
|
||||
return ts.toLocalDateTime().toLocalDate();
|
||||
}
|
||||
return value == null ? null : LocalDate.parse(value.toString().substring(0, 10));
|
||||
}
|
||||
|
||||
private static String firstNonBlank(final String... values) {
|
||||
for (final String v : values) {
|
||||
if (v != null && !v.isBlank()) {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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.simulator;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A stand-in for I4C's side of the integration, for local runs and UAT without the C-DOT VPN.
|
||||
*
|
||||
* It issues tokens, accepts the bank's response messages (keeping the last hundred so a tester can see exactly what the
|
||||
* bank would have sent I4C), and builds complaint pushes in the specification's shape. Only ever active with
|
||||
* {@code fineract.i4c.simulator.enabled=true}; never in production.
|
||||
*/
|
||||
@Component
|
||||
public class I4cSimulator {
|
||||
|
||||
/** Outbound base URL that routes the bank's calls to this simulator in-process instead of over HTTP. */
|
||||
public static final String INTERNAL_BASE_URL = "internal-simulator";
|
||||
|
||||
private static final int KEEP = 100;
|
||||
private final Deque<JsonObject> received = new ConcurrentLinkedDeque<>();
|
||||
|
||||
public String issueToken() {
|
||||
final JsonObject out = new JsonObject();
|
||||
out.addProperty("access_token", "SIM-" + UUID.randomUUID());
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
public String receiveResponse(final String body) {
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("receivedAt", Instant.now().toString());
|
||||
try {
|
||||
entry.add("message", JsonParser.parseString(body));
|
||||
} catch (final RuntimeException e) {
|
||||
entry.addProperty("raw", body);
|
||||
}
|
||||
received.addFirst(entry);
|
||||
while (received.size() > KEEP) {
|
||||
received.pollLast();
|
||||
}
|
||||
final JsonObject meta = new JsonObject();
|
||||
meta.addProperty("response_code", "00");
|
||||
meta.addProperty("response_message", "Success");
|
||||
final JsonObject out = new JsonObject();
|
||||
out.add("meta", meta);
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
public List<JsonObject> received() {
|
||||
return new ArrayList<>(received);
|
||||
}
|
||||
|
||||
/**
|
||||
* A complaint push in the specification's section 4 shape, from the few values a tester types. Defaults: UPI fraud,
|
||||
* layer 1, disputed amount equal to the amount, today's date.
|
||||
*/
|
||||
public static JsonObject buildComplaint(final JsonObject in) {
|
||||
final String ack = text(in, "acknowledgementNo", "3" + String.valueOf(System.currentTimeMillis()).substring(0, 13));
|
||||
final String amount = text(in, "amount", "1000.00");
|
||||
final JsonObject instrument = new JsonObject();
|
||||
instrument.addProperty("requestor", "I4C-MHA");
|
||||
instrument.addProperty("payer_bank", text(in, "payerBank", "Simulated Bank"));
|
||||
instrument.addProperty("payer_bank_code", text(in, "payerBankCode", "0"));
|
||||
instrument.addProperty("mode_of_payment", "Debit/Credit");
|
||||
instrument.addProperty("payer_mobile_number", text(in, "payerMobileNumber", "919999999999"));
|
||||
instrument.addProperty("payer_account_number", text(in, "accountNumber", ""));
|
||||
instrument.addProperty("state", text(in, "state", "DELHI"));
|
||||
instrument.addProperty("district", text(in, "district", "South Delhi"));
|
||||
instrument.addProperty("transaction_type", text(in, "transactionType", ""));
|
||||
instrument.addProperty("wallet", text(in, "wallet", "Phonepe"));
|
||||
|
||||
final JsonObject incident = new JsonObject();
|
||||
incident.addProperty("amount", amount);
|
||||
incident.addProperty("rrn", text(in, "rrn", String.valueOf(System.nanoTime()).substring(0, 12)));
|
||||
incident.addProperty("transaction_date", text(in, "transactionDate", java.time.LocalDate.now().toString()));
|
||||
incident.addProperty("transaction_time", text(in, "transactionTime", "12:00:00"));
|
||||
incident.addProperty("disputed_amount", text(in, "disputedAmount", amount));
|
||||
incident.addProperty("layer", text(in, "layer", "1"));
|
||||
final JsonArray incidents = new JsonArray();
|
||||
incidents.add(incident);
|
||||
|
||||
final JsonObject out = new JsonObject();
|
||||
out.addProperty("acknowledgement_no", ack);
|
||||
out.addProperty("sub_category", text(in, "subCategory", "UPI Related Frauds"));
|
||||
out.add("instrument", instrument);
|
||||
out.add("incidents", incidents);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String text(final JsonObject json, final String key, final String fallback) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() && !json.get(key).getAsString().isBlank() ? json.get(key).getAsString()
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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.simulator;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
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;
|
||||
|
||||
/**
|
||||
* HTTP face of {@link I4cSimulator}. Every endpoint answers 404 unless {@code fineract.i4c.simulator.enabled=true}, so
|
||||
* nothing here exists on a production server. All are POST because only POST is open on this path (SecurityConfig).
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code auth/token}, {@code bank/response} - what the bank calls when its outbound base URL points here.</li>
|
||||
* <li>{@code push} - builds a complaint from a few fields and feeds it in exactly as an I4C push would arrive. It skips
|
||||
* the inbound token check, so it is written to the API audit trail itself, as I4C to bank on
|
||||
* {@code internal-simulator/complaints}.</li>
|
||||
* <li>{@code received} - the responses the bank has sent, newest first.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Path("/v1/i4c-sim")
|
||||
@Component
|
||||
@Tag(name = "I4C simulator (UAT only)", description = "Local stand-in for I4C; off unless fineract.i4c.simulator.enabled=true.")
|
||||
@RequiredArgsConstructor
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class I4cSimulatorApiResource {
|
||||
|
||||
private final I4cProperties properties;
|
||||
private final I4cSimulator simulator;
|
||||
private final I4cRequestService requestService;
|
||||
private final I4cApiLogService apiLog;
|
||||
|
||||
@POST
|
||||
@Path("auth/token")
|
||||
public Response token(final String body) {
|
||||
if (!properties.getSimulator().isEnabled()) {
|
||||
return notFound();
|
||||
}
|
||||
return ok(simulator.issueToken());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("bank/response")
|
||||
public Response response(final String body) {
|
||||
if (!properties.getSimulator().isEnabled()) {
|
||||
return notFound();
|
||||
}
|
||||
return ok(simulator.receiveResponse(body));
|
||||
}
|
||||
|
||||
/** The section 5 ack, when the bank is configured to send it as a call of its own (outbound.ack-path). */
|
||||
@POST
|
||||
@Path("bank/ack")
|
||||
public Response ack(final String body) {
|
||||
if (!properties.getSimulator().isEnabled()) {
|
||||
return notFound();
|
||||
}
|
||||
return ok(simulator.receiveResponse(body));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("received")
|
||||
public Response received() {
|
||||
if (!properties.getSimulator().isEnabled()) {
|
||||
return notFound();
|
||||
}
|
||||
final JsonArray out = new JsonArray();
|
||||
simulator.received().forEach(out::add);
|
||||
return ok(out.toString());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("push")
|
||||
@Operation(summary = "Simulate an I4C complaint push: {accountNumber, amount, disputedAmount, rrn, transactionDate, layer, subCategory}")
|
||||
public Response push(final String body, @Context final HttpServletRequest http) {
|
||||
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);
|
||||
final JsonObject out = new JsonObject();
|
||||
out.add("request", complaint);
|
||||
if (!parsed.isValid()) {
|
||||
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();
|
||||
}
|
||||
|
||||
private static Response notFound() {
|
||||
return Response.status(404).type(MediaType.APPLICATION_JSON).entity("{\"message\":\"Not found\"}").build();
|
||||
}
|
||||
}
|
||||
+3
@@ -165,6 +165,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
this.jdbcTemplate.update("delete from m_client_address where client_id = ?", clientId);
|
||||
this.jdbcTemplate.update("delete from m_address where client_id = ?", clientId);
|
||||
this.jdbcTemplate.update("delete from m_client_kyc where client_id = ?", clientId);
|
||||
// A pending client can carry an edit request still waiting for a checker; its foreign key to m_client would
|
||||
// otherwise stop the delete (surfacing as "Unknown data integrity issue"). Deleting the client voids it.
|
||||
this.jdbcTemplate.update("delete from m_client_pending_edit where client_id = ?", clientId);
|
||||
|
||||
this.clientRepository.delete(client);
|
||||
this.clientRepository.flush();
|
||||
|
||||
+25
@@ -113,6 +113,7 @@ public class KycRestrictionGuard {
|
||||
* (tracking flag and dormancy days): a current account is dormant on its product's schedule, not a savings one's.
|
||||
*/
|
||||
public void checkCurrentAccount(final Long currentAccountId, final Operation operation) {
|
||||
checkCurrentAccountBlock(currentAccountId, operation);
|
||||
checkCurrentAccountDormancy(currentAccountId, operation);
|
||||
enforceKycStage(currentAccountDaysOverdue(currentAccountId), operation);
|
||||
}
|
||||
@@ -173,6 +174,30 @@ public class KycRestrictionGuard {
|
||||
refuseIfDormant(rows, operation, "savings");
|
||||
}
|
||||
|
||||
/**
|
||||
* Honours a block on a current account the way a savings account honours its own: BLOCK (400) stops every
|
||||
* movement, BLOCK_DEBIT (600) stops money leaving, BLOCK_CREDIT (500) stops money arriving. Until this check the
|
||||
* current-account sub-status was only displayed, so a debit freeze placed on a current account (an LEA / court
|
||||
* direction, an I4C freeze) did not stop anything.
|
||||
*/
|
||||
private void checkCurrentAccountBlock(final Long currentAccountId, final Operation operation) {
|
||||
if (currentAccountId == null) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT sub_status_enum, reason_for_block FROM m_current_account WHERE id = ?", currentAccountId);
|
||||
if (rows.isEmpty() || rows.get(0).get("sub_status_enum") == null) {
|
||||
return;
|
||||
}
|
||||
final int subStatus = ((Number) rows.get(0).get("sub_status_enum")).intValue();
|
||||
final boolean outgoing = operation != Operation.CREDIT;
|
||||
if (subStatus == 400 || (outgoing && subStatus == 600) || (!outgoing && subStatus == 500)) {
|
||||
final Object reason = rows.get(0).get("reason_for_block");
|
||||
throw new PlatformDataIntegrityException("error.msg.current.account.blocked." + operation.name().toLowerCase(),
|
||||
operation.getLabel() + " is not allowed: the current account is blocked" + (reason == null ? "." : " (" + reason + ")."));
|
||||
}
|
||||
}
|
||||
|
||||
private void checkCurrentAccountDormancy(final Long currentAccountId, final Operation operation) {
|
||||
if (currentAccountId == null || operation == Operation.CREDIT) {
|
||||
return;
|
||||
|
||||
+26
-2
@@ -66,10 +66,10 @@ public class FundTransfer extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "cashier_user_id", nullable = false)
|
||||
private Long cashierUserId;
|
||||
|
||||
@Column(name = "client_id", nullable = false)
|
||||
@Column(name = "client_id")
|
||||
private Long clientId;
|
||||
|
||||
@Column(name = "payer_account_id", nullable = false)
|
||||
@Column(name = "payer_account_id")
|
||||
private Long payerAccountId;
|
||||
|
||||
@Column(name = "payer_account_number", nullable = false, length = 64)
|
||||
@@ -110,4 +110,28 @@ public class FundTransfer extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@Column(name = "settled_at")
|
||||
private LocalDateTime settledAt;
|
||||
|
||||
/** How the money was parked: ACCOUNT (legacy suspense savings account), GL (outward suspense GL), GL_SOURCE. */
|
||||
@Column(name = "park_mode", length = 12)
|
||||
private String parkMode;
|
||||
|
||||
/** For a transfer paid from a GL (e.g. I4C restoration payable) rather than a customer account. */
|
||||
@Column(name = "source_gl_account_id")
|
||||
private Long sourceGlAccountId;
|
||||
|
||||
@Column(name = "source_office_id")
|
||||
private Long sourceOfficeId;
|
||||
|
||||
/** What the GL-sourced payment is for, e.g. the I4C restoration id. */
|
||||
@Column(name = "source_reference", length = 64)
|
||||
private String sourceReference;
|
||||
|
||||
@Column(name = "park_journal_ref", length = 64)
|
||||
private String parkJournalRef;
|
||||
|
||||
@Column(name = "settlement_journal_ref", length = 64)
|
||||
private String settlementJournalRef;
|
||||
|
||||
@Column(name = "reversal_journal_ref", length = 64)
|
||||
private String reversalJournalRef;
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Refuses a fund transfer from an account whose digital channels are suspended (m_i4c_channel_block): the MHA
|
||||
* NCRP-CFCFRMS SOP lets a bank suspend RTGS, NEFT, IMPS and other digital banking on a repeatedly reported account.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ChannelBlockGuard {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public void requireOpen(final String accountType, final Long accountId, final String channel) {
|
||||
if (accountId == null || channel == null) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> blocks = jdbcTemplate.queryForList(
|
||||
"SELECT channels, reason FROM m_i4c_channel_block WHERE account_type = ? AND account_id = ? AND status = 'ACTIVE'", accountType,
|
||||
accountId);
|
||||
for (final Map<String, Object> b : blocks) {
|
||||
final String channels = String.valueOf(b.get("channels"));
|
||||
if (Arrays.stream(channels.split(",")).map(String::trim).anyMatch(channel::equalsIgnoreCase)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.channel.suspended",
|
||||
channel + " is suspended on this account (" + b.get("reason") + "). Contact the branch.", accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.account.service.AccountBookDebitService;
|
||||
import org.apache.fineract.portfolio.clearing.service.ClearingGlAccountResolverService;
|
||||
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* The books of an outward (OFF_US) NEFT / IMPS / RTGS transfer.
|
||||
*
|
||||
* <p>
|
||||
* With the GL roles {@code FUNDTRANSFER_OUTWARD_SUSPENSE} (liability) and {@code FUNDTRANSFER_SETTLEMENT} (balance
|
||||
* with the sponsor bank / RBI, an asset) mapped on the Clearing GL Configuration screen:
|
||||
*
|
||||
* <pre>
|
||||
* park Dr payer Savings Control (book debit) Cr OUTWARD_SUSPENSE -- or Dr source GL for a GL-sourced transfer
|
||||
* settle Dr OUTWARD_SUSPENSE Cr SETTLEMENT -- when the switch reports SUCCESS
|
||||
* reverse Dr OUTWARD_SUSPENSE (or SETTLEMENT once settled) Cr payer Savings Control (book credit) / source GL
|
||||
* </pre>
|
||||
*
|
||||
* The money therefore leaves customer deposits when it is sent and leaves the bank when it settles, and the suspense
|
||||
* GL holds only what is in flight. Without the suspense GL mapped, the earlier behaviour (a savings "suspense" account,
|
||||
* no settlement entry) is kept so nothing changes until the bank maps it; a GL-sourced transfer needs the GL mode.
|
||||
* Every posting joins the caller's transaction.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FundTransferLedgerService {
|
||||
|
||||
public static final String OUTWARD_SUSPENSE = "FUNDTRANSFER_OUTWARD_SUSPENSE";
|
||||
public static final String SETTLEMENT = "FUNDTRANSFER_SETTLEMENT";
|
||||
public static final String MODE_ACCOUNT = "ACCOUNT";
|
||||
public static final String MODE_GL = "GL";
|
||||
public static final String MODE_GL_SOURCE = "GL_SOURCE";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FundTransferLedgerService.class);
|
||||
|
||||
private final SuspenseGlService legacySuspense;
|
||||
private final ClearingGlAccountResolverService glResolver;
|
||||
private final AccountingProcessorHelper accounting;
|
||||
private final AccountBookDebitService bookEntries;
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** True when outward transfers are booked through the outward suspense GL. */
|
||||
public boolean glMode() {
|
||||
return glResolver.resolveClearingGlAccountId(OUTWARD_SUSPENSE) != null;
|
||||
}
|
||||
|
||||
/** Takes the money for an outward transfer: from the payer's account, or from the source GL. */
|
||||
public void park(final FundTransfer t) {
|
||||
final Long suspense = glResolver.resolveClearingGlAccountId(OUTWARD_SUSPENSE);
|
||||
final LocalDate date = DateUtils.getBusinessLocalDate();
|
||||
if (t.getSourceGlAccountId() != null) {
|
||||
if (suspense == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.gl.mode.required",
|
||||
"A payment from a GL needs the FUNDTRANSFER_OUTWARD_SUSPENSE GL mapped (Clearing GL Configuration).");
|
||||
}
|
||||
final String ref = t.getTxnRef() + "-P";
|
||||
accounting.createGlToGlJournalEntries(t.getSourceOfficeId(), currency(null), t.getSourceGlAccountId(), suspense, null, ref, date,
|
||||
t.getAmount());
|
||||
t.setParkMode(MODE_GL_SOURCE);
|
||||
t.setParkJournalRef(ref);
|
||||
return;
|
||||
}
|
||||
if (suspense == null) {
|
||||
final Long legId = legacySuspense.parkInSuspense(t);
|
||||
t.setParkMode(MODE_ACCOUNT);
|
||||
if (legId != null) {
|
||||
t.setInternalTxnId(legId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final Long payerId = t.getPayerAccountId();
|
||||
final Long control = controlGl(payerId);
|
||||
final Long officeId = officeOf(payerId);
|
||||
final long txnId = bookEntries.debitSavings(payerId, t.getAmount(), officeId, date, userId());
|
||||
final String ref = t.getTxnRef() + "-P";
|
||||
accounting.createGlToGlJournalEntries(officeId, currency(payerId), control, suspense, payerId, ref, date, t.getAmount());
|
||||
t.setParkMode(MODE_GL);
|
||||
t.setInternalTxnId(txnId);
|
||||
t.setParkJournalRef(ref);
|
||||
}
|
||||
|
||||
/** The switch reported SUCCESS: the money has left the bank, so the suspense is cleared to the settlement account. */
|
||||
public void settle(final FundTransfer t) {
|
||||
if (!isGlParked(t) || t.getSettlementJournalRef() != null) {
|
||||
return;
|
||||
}
|
||||
final Long suspense = glResolver.resolveClearingGlAccountId(OUTWARD_SUSPENSE);
|
||||
final Long settlement = glResolver.resolveClearingGlAccountId(SETTLEMENT);
|
||||
if (suspense == null || settlement == null) {
|
||||
log.warn("[FundTransferLedger] txnRef={} succeeded but {} / {} is not mapped; it stays in outward suspense until settled",
|
||||
t.getTxnRef(), OUTWARD_SUSPENSE, SETTLEMENT);
|
||||
return;
|
||||
}
|
||||
final String ref = t.getTxnRef() + "-S";
|
||||
accounting.createGlToGlJournalEntries(officeFor(t), currency(t.getPayerAccountId()), suspense, settlement, t.getPayerAccountId(), ref,
|
||||
DateUtils.getBusinessLocalDate(), t.getAmount());
|
||||
t.setSettlementJournalRef(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transfer failed, was rejected after parking, or was recalled: the money goes back where it came from. From
|
||||
* the settlement account once settled (the other bank returned it), otherwise from the outward suspense.
|
||||
*/
|
||||
public void reverse(final FundTransfer t) {
|
||||
if (!isGlParked(t)) {
|
||||
if (MODE_ACCOUNT.equals(t.getParkMode()) || t.getParkMode() == null) {
|
||||
legacySuspense.reverseFromSuspense(t);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.getReversalJournalRef() != null) {
|
||||
return;
|
||||
}
|
||||
final Long from = glResolver.resolveClearingGlAccountId(t.getSettlementJournalRef() != null ? SETTLEMENT : OUTWARD_SUSPENSE);
|
||||
if (from == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.gl.unmapped",
|
||||
"The outward suspense / settlement GL is no longer mapped; map it before reversing " + t.getTxnRef() + ".");
|
||||
}
|
||||
final LocalDate date = DateUtils.getBusinessLocalDate();
|
||||
final String ref = t.getTxnRef() + "-R";
|
||||
if (MODE_GL_SOURCE.equals(t.getParkMode())) {
|
||||
accounting.createGlToGlJournalEntries(t.getSourceOfficeId(), currency(null), from, t.getSourceGlAccountId(), null, ref, date,
|
||||
t.getAmount());
|
||||
} else {
|
||||
final Long payerId = t.getPayerAccountId();
|
||||
final Long officeId = officeOf(payerId);
|
||||
bookEntries.creditSavings(payerId, t.getAmount(), officeId, date, userId());
|
||||
accounting.createGlToGlJournalEntries(officeId, currency(payerId), from, controlGl(payerId), payerId, ref, date, t.getAmount());
|
||||
}
|
||||
t.setReversalJournalRef(ref);
|
||||
}
|
||||
|
||||
private boolean isGlParked(final FundTransfer t) {
|
||||
return MODE_GL.equals(t.getParkMode()) || MODE_GL_SOURCE.equals(t.getParkMode());
|
||||
}
|
||||
|
||||
private Long officeFor(final FundTransfer t) {
|
||||
return t.getSourceOfficeId() != null ? t.getSourceOfficeId() : officeOf(t.getPayerAccountId());
|
||||
}
|
||||
|
||||
private Long controlGl(final Long savingsId) {
|
||||
final Long control = glResolver.resolveControlGlAccountId("SAVINGS", savingsId);
|
||||
if (control == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.control.gl.missing",
|
||||
"The payer's savings product has no Savings Control GL mapped.", savingsId);
|
||||
}
|
||||
return control;
|
||||
}
|
||||
|
||||
private Long officeOf(final Long savingsId) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT c.office_id FROM m_savings_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", savingsId);
|
||||
if (rows.isEmpty() || rows.get(0).get("office_id") == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.fundtransfer.payer.office", "Payer account has no branch.", savingsId);
|
||||
}
|
||||
return ((Number) rows.get(0).get("office_id")).longValue();
|
||||
}
|
||||
|
||||
private String currency(final Long savingsId) {
|
||||
if (savingsId == null) {
|
||||
return "INR";
|
||||
}
|
||||
final List<String> codes = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class,
|
||||
savingsId);
|
||||
return codes.isEmpty() || codes.get(0) == null ? "INR" : codes.get(0);
|
||||
}
|
||||
|
||||
private Long userId() {
|
||||
return context.authenticatedUser().getId();
|
||||
}
|
||||
|
||||
}
|
||||
+9
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.fundtransfer.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyRequestData;
|
||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyResponseData;
|
||||
@@ -33,4 +34,12 @@ public interface FundTransferWritePlatformService {
|
||||
TransferResponseData reject(String txnRef, String reason);
|
||||
|
||||
TransferResponseData recall(String txnRef, String reason);
|
||||
|
||||
/**
|
||||
* An outward NEFT / IMPS / RTGS paid from a bank GL rather than a customer account (an I4C restoration payable).
|
||||
* Already approved by the caller's own maker-checker, so it runs at once; the channel's time window and the payee
|
||||
* screening still apply. Needs the outward suspense GL mapped.
|
||||
*/
|
||||
TransferResponseData createFromGl(String channel, Long sourceGlAccountId, Long sourceOfficeId, String sourceReference,
|
||||
String payeeAccountNumber, String payeeIfsc, String payeeName, BigDecimal amount, String remarks);
|
||||
}
|
||||
|
||||
+58
-10
@@ -76,7 +76,8 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
|
||||
private final ImpsSwitchClient impsSwitchClient;
|
||||
private final SanctionsScreeningService sanctionsScreeningService;
|
||||
private final TransferChannelWindowValidator windowValidator;
|
||||
private final SuspenseGlService suspenseGl;
|
||||
private final FundTransferLedgerService ledger;
|
||||
private final ChannelBlockGuard channelBlockGuard;
|
||||
private final AmlFundTransferEvaluationService amlFundTransferEvaluationService;
|
||||
|
||||
// ─── /verify ──────────────────────────────────────────────────────────
|
||||
@@ -97,6 +98,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
|
||||
if (clientId == null) {
|
||||
throw new IllegalArgumentException("payer account is not owned by a client");
|
||||
}
|
||||
channelBlockGuard.requireOpen("SAVINGS", payer.getId(), channel.name());
|
||||
|
||||
ResolvedLimits limits = limitResolver.resolve(clientId, channel);
|
||||
validateAmountAgainstLimits(request.getAmount(), limits);
|
||||
@@ -175,6 +177,51 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
|
||||
return FundTransferReadPlatformServiceImpl.toData(saved);
|
||||
}
|
||||
|
||||
// ─── outward paid from a GL (I4C restoration) ─────────────────────────
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public TransferResponseData createFromGl(String channelName, Long sourceGlAccountId, Long sourceOfficeId, String sourceReference,
|
||||
String payeeAccountNumber, String payeeIfsc, String payeeName, BigDecimal amount, String remarks) {
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalArgumentException("amount must be > 0");
|
||||
}
|
||||
if (sourceGlAccountId == null || sourceOfficeId == null) {
|
||||
throw new IllegalArgumentException("source GL and office are required");
|
||||
}
|
||||
TransferChannel channel = TransferChannel.from(channelName);
|
||||
if (channel == TransferChannel.INTERNAL || resolveDirection(channel, payeeIfsc) != TransferDirection.OFF_US) {
|
||||
throw new IllegalArgumentException("A payment from a GL goes only to another bank (IFSC not ours)");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
windowValidator.requireOpen(channel, LocalTime.from(now), now.getDayOfWeek());
|
||||
screenOffUsPayee(payeeName, null);
|
||||
AppUser actor = context.authenticatedUser();
|
||||
|
||||
FundTransfer transfer = new FundTransfer();
|
||||
transfer.setTxnRef(generateRef());
|
||||
transfer.setChannel(channel);
|
||||
transfer.setDirection(TransferDirection.OFF_US);
|
||||
transfer.setStatus(FundTransferStatus.VERIFIED);
|
||||
transfer.setApprovalStatus(ApprovalStatus.AUTO);
|
||||
transfer.setCashierUserId(actor.getId());
|
||||
transfer.setPayerAccountNumber("GL-" + sourceGlAccountId);
|
||||
transfer.setPayeeAccountNumber(payeeAccountNumber);
|
||||
transfer.setPayeeIfsc(payeeIfsc == null ? null : payeeIfsc.trim().toUpperCase(java.util.Locale.ROOT));
|
||||
transfer.setPayeeName(payeeName);
|
||||
transfer.setAmount(amount);
|
||||
transfer.setRemarks(remarks);
|
||||
transfer.setInitiatedAt(now);
|
||||
transfer.setSourceGlAccountId(sourceGlAccountId);
|
||||
transfer.setSourceOfficeId(sourceOfficeId);
|
||||
transfer.setSourceReference(sourceReference);
|
||||
|
||||
executeStrategy(transfer);
|
||||
FundTransfer saved = fundTransferRepository.save(transfer);
|
||||
eventRepository.save(FundTransferEvent.of(saved.getId(), null, saved.getStatus(), "INITIATED_FROM_GL", sourceReference, actor.getId()));
|
||||
return FundTransferReadPlatformServiceImpl.toData(saved);
|
||||
}
|
||||
|
||||
// ─── POST /v1/transfers/{ref}/approve ─────────────────────────────────
|
||||
|
||||
@Override
|
||||
@@ -236,7 +283,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
|
||||
}
|
||||
AppUser actor = context.authenticatedUser();
|
||||
String switchOutcome = notifySwitchOfRecall(t);
|
||||
suspenseGl.reverseFromSuspense(t);
|
||||
ledger.reverse(t);
|
||||
t.setStatus(FundTransferStatus.REVERSED);
|
||||
t.setResponseCode("RCL");
|
||||
t.setResponseMessage(reason == null ? "Recall initiated by ops" : reason);
|
||||
@@ -277,14 +324,12 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
|
||||
// ─── internals ────────────────────────────────────────────────────────
|
||||
|
||||
private void executeStrategy(FundTransfer transfer) {
|
||||
// A suspension placed between verify and approval still stops the money.
|
||||
channelBlockGuard.requireOpen("SAVINGS", transfer.getPayerAccountId(), transfer.getChannel().name());
|
||||
transfer.setStatus(FundTransferStatus.IN_PROGRESS);
|
||||
|
||||
Long suspenseLegId = null;
|
||||
if (transfer.getDirection() == TransferDirection.OFF_US) {
|
||||
suspenseLegId = suspenseGl.parkInSuspense(transfer);
|
||||
if (suspenseLegId != null) {
|
||||
transfer.setInternalTxnId(suspenseLegId);
|
||||
}
|
||||
ledger.park(transfer);
|
||||
}
|
||||
|
||||
TransferStrategy strategy = router.strategyFor(transfer);
|
||||
@@ -298,9 +343,12 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
|
||||
transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
}
|
||||
|
||||
if (transfer.getDirection() == TransferDirection.OFF_US && suspenseLegId != null
|
||||
&& transfer.getStatus() == FundTransferStatus.FAILED) {
|
||||
suspenseGl.reverseFromSuspense(transfer);
|
||||
if (transfer.getDirection() == TransferDirection.OFF_US) {
|
||||
if (transfer.getStatus() == FundTransferStatus.FAILED) {
|
||||
ledger.reverse(transfer);
|
||||
} else if (transfer.getStatus() == FundTransferStatus.SUCCESS) {
|
||||
ledger.settle(transfer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -59,7 +59,7 @@ public class ImpsDeemedReconciliationService {
|
||||
private final FundTransferRepository fundTransferRepository;
|
||||
private final FundTransferEventRepository eventRepository;
|
||||
private final ImpsSwitchClient switchClient;
|
||||
private final SuspenseGlService suspenseGl;
|
||||
private final FundTransferLedgerService ledger;
|
||||
|
||||
/** Picks all DEEMED rows in the window and tries to resolve each. Returns count touched. */
|
||||
public int reconcileAll() {
|
||||
@@ -110,7 +110,9 @@ public class ImpsDeemedReconciliationService {
|
||||
}
|
||||
transfer.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
if (newStatus == FundTransferStatus.FAILED) {
|
||||
suspenseGl.reverseFromSuspense(transfer);
|
||||
ledger.reverse(transfer);
|
||||
} else if (newStatus == FundTransferStatus.SUCCESS) {
|
||||
ledger.settle(transfer);
|
||||
}
|
||||
fundTransferRepository.save(transfer);
|
||||
eventRepository.save(FundTransferEvent.of(transfer.getId(), prev, newStatus, "RECONCILED",
|
||||
|
||||
+5
-3
@@ -54,7 +54,7 @@ public class NeftBatchProcessorService {
|
||||
private final FundTransferRepository fundTransferRepository;
|
||||
private final FundTransferEventRepository eventRepository;
|
||||
private final ImpsSwitchClient switchClient;
|
||||
private final SuspenseGlService suspenseGl;
|
||||
private final FundTransferLedgerService ledger;
|
||||
private final AmlFundTransferEvaluationService amlFundTransferEvaluationService;
|
||||
|
||||
@Value("${fineract.imps.our-default-ifsc:BANK0000001}")
|
||||
@@ -106,7 +106,9 @@ public class NeftBatchProcessorService {
|
||||
t.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
}
|
||||
if (mapped == FundTransferStatus.FAILED) {
|
||||
suspenseGl.reverseFromSuspense(t);
|
||||
ledger.reverse(t);
|
||||
} else if (mapped == FundTransferStatus.SUCCESS) {
|
||||
ledger.settle(t);
|
||||
}
|
||||
fundTransferRepository.save(t);
|
||||
eventRepository.save(FundTransferEvent.of(t.getId(), prev, mapped, "NEFT_BATCH_PROCESSED",
|
||||
@@ -119,7 +121,7 @@ public class NeftBatchProcessorService {
|
||||
t.setResponseCode("ERR");
|
||||
t.setResponseMessage(ex.getMessage());
|
||||
t.setSettledAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
suspenseGl.reverseFromSuspense(t);
|
||||
ledger.reverse(t);
|
||||
fundTransferRepository.save(t);
|
||||
eventRepository.save(FundTransferEvent.of(t.getId(), prev, FundTransferStatus.FAILED,
|
||||
"NEFT_BATCH_ERROR", ex.toString(), null));
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationDueData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentAmortisationService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/amortisation")
|
||||
@Component
|
||||
@Tag(name = "Investment Premium Amortisation", description = "Premium of HTM holdings amortised straight-line to maturity every 31 March.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentAmortisationApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentAmortisationService amortisationService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Path("{purchaseId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Amortisation schedule of a holding (06 Amortize Entry)")
|
||||
public InvestmentAmortisationData schedule(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
|
||||
return this.amortisationService.schedule(purchaseId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("due")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Holdings due for amortisation on a 31 March (07 Amortize Yearly Process)", description = "fyEnd dd-MM-yyyy")
|
||||
public List<InvestmentAmortisationDueData> due(@QueryParam("fyEnd") @Parameter(description = "dd-MM-yyyy") final String fyEnd) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
|
||||
return this.amortisationService.due(InvestmentApiDates.parse(fyEnd, null, "financial year end"));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Post amortisation for a 31 March", description = "With purchaseId: that holding (Amortize Entry); without: "
|
||||
+ "every holding due (Amortize Yearly Process). One JV per holding: Dr 500604 / Cr premium GL.")
|
||||
public CommandProcessingResult amortise(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().amortiseInvestmentPremium().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Constants for the Treasury Investment masters (Bank / Party Master and Script Master), modelled on the bank's existing
|
||||
* CBS Investment screens.
|
||||
*/
|
||||
public final class InvestmentApiConstants {
|
||||
|
||||
private InvestmentApiConstants() {}
|
||||
|
||||
public static final String ENTITY_PARTY = "INVESTMENTPARTY";
|
||||
public static final String ENTITY_SCRIPT = "INVESTMENTSCRIPT";
|
||||
public static final String RESOURCE_PARTY = "investmentparty";
|
||||
public static final String RESOURCE_SCRIPT = "investmentscript";
|
||||
|
||||
/** Instrument ("Bond Type") an investment party / script belongs to. */
|
||||
public static final Set<String> BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND", "FD", "SHARE");
|
||||
|
||||
/** Bond types that carry coupon terms (ISIN, interest rate, issue date are mandatory for these). */
|
||||
public static final Set<String> DEBT_BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND");
|
||||
|
||||
/** SLR classification on the Script Master ("GOI" = Central Government). */
|
||||
public static final Set<String> SLR_TYPES = Set.of("GOI", "STATE_GOVT", "TREASURY", "NON_SLR");
|
||||
|
||||
/** Script Code prefix per bond type, e.g. G00001 for the first Govt Security. */
|
||||
public static final Map<String, String> SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND",
|
||||
"M", "FD", "F", "SHARE", "S");
|
||||
|
||||
/** Bond types that are non-SLR investments, counted against the RBI limit of 10% of deposits. */
|
||||
public static final Set<String> NON_SLR_BOND_TYPES = Set.of("NON_SLR", "BOND", "MUTUAL_FUND");
|
||||
|
||||
/** Bond types that must carry a credit rating: corporate bonds, CPs and CDs. */
|
||||
public static final Set<String> RATED_BOND_TYPES = Set.of("NON_SLR", "BOND");
|
||||
|
||||
/** Credit ratings on the Script Master: long-term (bonds) and short-term (CP / CD) scales, best first. */
|
||||
public static final List<String> RATINGS = List.of("AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB", "BBB-", "BB+", "BB",
|
||||
"BB-", "B+", "B", "B-", "C", "D", "A1+", "A1", "A2+", "A2", "A3+", "A3", "A4+", "A4", "UNRATED");
|
||||
|
||||
/** RBI: corporate bonds rated "A" or higher, CPs and CDs rated "A2" or higher. */
|
||||
public static final Set<String> ELIGIBLE_RATINGS = Set.of("AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "A1+", "A1", "A2+", "A2");
|
||||
|
||||
/** Short-term scale, for CPs and CDs (issued for up to one year); bonds carry the long-term scale. */
|
||||
public static final Set<String> SHORT_TERM_RATINGS = Set.of("A1+", "A1", "A2+", "A2", "A3+", "A3", "A4+", "A4");
|
||||
|
||||
/** Credit rating agencies registered with SEBI. */
|
||||
public static final List<String> RATING_AGENCIES = List.of("CRISIL", "ICRA", "CARE", "INDIA_RATINGS", "ACUITE", "BRICKWORK",
|
||||
"INFOMERICS");
|
||||
|
||||
/** RBI: only debt and money-market mutual funds; their sub types start with this. */
|
||||
public static final String DEBT_MF_SUB_TYPE_PREFIX = "DEBT";
|
||||
|
||||
/** Share investments: only these institutions (no shares of companies outside the co-operative sector). */
|
||||
public static final String SHARE = "SHARE";
|
||||
public static final String INSTITUTION_STATE_COOP_BANK = "STATE_COOP_BANK";
|
||||
public static final String INSTITUTION_DCCB = "DCCB";
|
||||
public static final String INSTITUTION_OTHER_COOP_SOCIETY = "OTHER_COOP_SOCIETY";
|
||||
/** Membership shares of NPCI, CCIL, SWIFT, the Umbrella Organisation of UCBs and IDPIC. */
|
||||
public static final String INSTITUTION_MEMBERSHIP_MIC_UO = "MEMBERSHIP_MIC_UO";
|
||||
public static final Set<String> INSTITUTION_TYPES = Set.of(INSTITUTION_STATE_COOP_BANK, INSTITUTION_DCCB, INSTITUTION_OTHER_COOP_SOCIETY,
|
||||
INSTITUTION_MEMBERSHIP_MIC_UO);
|
||||
/** Co-operative institutions: their shares are valued on the dividend / balance sheet rules. */
|
||||
public static final Set<String> COOP_INSTITUTION_TYPES = Set.of(INSTITUTION_STATE_COOP_BANK, INSTITUTION_DCCB,
|
||||
INSTITUTION_OTHER_COOP_SOCIETY);
|
||||
|
||||
public static final String PARTY_CODE_PREFIX = "PTY-";
|
||||
|
||||
// Common parameters
|
||||
public static final String BOND_TYPE = "bondType";
|
||||
public static final String GL_ACCOUNT_ID = "glAccountId";
|
||||
public static final String INTEREST_GL_ACCOUNT_ID = "interestGlAccountId";
|
||||
public static final String ADDRESS1 = "address1";
|
||||
public static final String ADDRESS2 = "address2";
|
||||
public static final String ADDRESS3 = "address3";
|
||||
public static final String CITY = "city";
|
||||
public static final String PINCODE = "pincode";
|
||||
public static final String PHONE = "phone";
|
||||
public static final String FAX = "fax";
|
||||
public static final String ACTIVE = "active";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
// Party parameters
|
||||
public static final String PARTY_NAME = "partyName";
|
||||
public static final String BANK_TYPE = "bankType";
|
||||
public static final String RBI_CODE = "rbiCode";
|
||||
public static final String EXPOSURE_LIMIT = "limit";
|
||||
public static final String LEI_NO = "leiNo";
|
||||
public static final String CP_BANK_NAME = "cpBankName";
|
||||
public static final String CP_BRANCH_NAME = "cpBranchName";
|
||||
public static final String CP_ACCOUNT_NO = "cpAccountNo";
|
||||
public static final String CP_IFSC = "cpIfsc";
|
||||
public static final String TDS_APPLICABLE = "tdsApplicable";
|
||||
public static final String TDS_RATE = "tdsRate";
|
||||
public static final String INSTITUTION_TYPE = "institutionType";
|
||||
public static final String SUBSCRIBED_CAPITAL = "subscribedCapital";
|
||||
public static final String LAST_BALANCE_SHEET_DATE = "lastBalanceSheetDate";
|
||||
public static final String IN_LIQUIDATION = "inLiquidation";
|
||||
|
||||
// Script parameters
|
||||
public static final String SLR_TYPE = "slrType";
|
||||
public static final String SCRIPT_DESC = "scriptDesc";
|
||||
public static final String ISIN = "isin";
|
||||
public static final String FACE_VALUE_PER_UNIT = "faceValuePerUnit";
|
||||
public static final String INT_RATE = "intRate";
|
||||
public static final String ISSUE_DATE = "issueDate";
|
||||
public static final String MATURITY_DATE = "maturityDate";
|
||||
public static final String COMPANY_NAME = "companyName";
|
||||
public static final String CORP_CODE = "corpCode";
|
||||
public static final String RATING = "rating";
|
||||
public static final String RATING_AGENCY = "ratingAgency";
|
||||
public static final String LISTED = "listed";
|
||||
|
||||
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX,
|
||||
BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
|
||||
TDS_APPLICABLE, TDS_RATE, INSTITUTION_TYPE, SUBSCRIBED_CAPITAL, LAST_BALANCE_SHEET_DATE, IN_LIQUIDATION, LOCALE, DATE_FORMAT);
|
||||
|
||||
public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT,
|
||||
INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE,
|
||||
RATING, RATING_AGENCY, LISTED, ACTIVE, LOCALE, DATE_FORMAT);
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.format.ResolverStyle;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
|
||||
/** Query-parameter dates of the Investment APIs: DD-MM-YYYY, strictly (31-02 is refused, not moved to month end). */
|
||||
public final class InvestmentApiDates {
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(ResolverStyle.STRICT);
|
||||
|
||||
private InvestmentApiDates() {}
|
||||
|
||||
/** The date in {@code value}, or {@code fallback} when it is blank; a malformed date is a clear 403, not a 500. */
|
||||
public static LocalDate parse(final String value, final LocalDate fallback, final String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
if (fallback == null) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.date.required", "Enter the " + name + " as DD-MM-YYYY",
|
||||
name);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim(), DATE);
|
||||
} catch (final DateTimeParseException e) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.date.invalid", "Enter the " + name + " as DD-MM-YYYY",
|
||||
value);
|
||||
}
|
||||
}
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.format.ResolverStyle;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.investment.data.InvestmentBucketReportData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentBucketReportService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/bucket-report")
|
||||
@Component
|
||||
@Tag(name = "Investment Bucket Wise", description = "Investments held on a date, split by the time left to maturity.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentBucketReportApiResource {
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(ResolverStyle.STRICT);
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentBucketReportService reportService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Bucket Wise Investment", description = "asOnDate dd-MM-yyyy; default today")
|
||||
public InvestmentBucketReportData report(@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
if (asOnDate == null || asOnDate.isBlank()) {
|
||||
return this.reportService.report(DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
final LocalDate asOn;
|
||||
try {
|
||||
asOn = LocalDate.parse(asOnDate.trim(), DATE);
|
||||
} catch (final DateTimeParseException e) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.date.invalid", "Enter the date as DD-MM-YYYY",
|
||||
asOnDate);
|
||||
}
|
||||
if (asOn.isAfter(DateUtils.getBusinessLocalDate())) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.future", "The as-on date cannot be after today");
|
||||
}
|
||||
return this.reportService.report(asOn);
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Constants for the Daily Script Master (market rate of a script for a day). */
|
||||
public final class InvestmentDailyRateApiConstants {
|
||||
|
||||
private InvestmentDailyRateApiConstants() {}
|
||||
|
||||
public static final String ENTITY = "INVESTMENTDAILYRATE";
|
||||
public static final String RESOURCE = "investmentdailyrate";
|
||||
|
||||
public static final String SCRIPT_ID = "scriptId";
|
||||
public static final String RATE_DATE = "rateDate";
|
||||
public static final String OPEN_RATE = "openRate";
|
||||
public static final String HIGH_RATE = "highRate";
|
||||
public static final String LOW_RATE = "lowRate";
|
||||
public static final String CLOSE_RATE = "closeRate";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
public static final Set<String> PARAMS = Set.of(SCRIPT_ID, RATE_DATE, OPEN_RATE, HIGH_RATE, LOW_RATE, CLOSE_RATE, LOCALE, DATE_FORMAT);
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentDailyRateData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentDailyRateReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/daily-rates")
|
||||
@Component
|
||||
@Tag(name = "Investment Daily Script Master", description = "Market rate of each script for a day; Purchase Entry takes its rate from here.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentDailyRateApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentDailyRateReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List daily script rates, newest first")
|
||||
public List<InvestmentDailyRateData> retrieveAll(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentDailyRateApiConstants.ENTITY);
|
||||
return this.readPlatformService.retrieveAll(scriptId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("latest")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Newest rate of a script on or before a date", description = "onDate (dd-MM-yyyy) defaults to the business "
|
||||
+ "date. Returns no content when no rate has been entered for the script.")
|
||||
public InvestmentDailyRateData retrieveLatest(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
|
||||
@QueryParam("onDate") @Parameter(description = "dd-MM-yyyy") final String onDate) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentDailyRateApiConstants.ENTITY);
|
||||
final LocalDate on = InvestmentApiDates.parse(onDate, DateUtils.getBusinessLocalDate(), "date");
|
||||
return this.readPlatformService.retrieveLatest(scriptId, on);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Save a daily script rate")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentDailyRate().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{rateId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Drop a daily script rate")
|
||||
public CommandProcessingResult delete(@PathParam("rateId") @Parameter(description = "rateId") final Long rateId) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentDailyRate(rateId).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.investment.data.InvestmentDiaryData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentDiaryService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/diary")
|
||||
@Component
|
||||
@Tag(name = "Investment Due Date Diary", description = "Coupons, provisions, amortisation, FD payouts and maturities due in a period.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentDiaryApiResource {
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
/** Default window when no dates are given: today and the next 30 days. */
|
||||
private static final int DEFAULT_DAYS = 30;
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentDiaryService diaryService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Due Date Diary for all investments", description = "fromDate / toDate dd-MM-yyyy; default today to 30 days ahead")
|
||||
public InvestmentDiaryData diary(@QueryParam("fromDate") @Parameter(description = "dd-MM-yyyy") final String fromDate,
|
||||
@QueryParam("toDate") @Parameter(description = "dd-MM-yyyy") final String toDate) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate from = date(fromDate, today);
|
||||
final LocalDate to = date(toDate, from.plusDays(DEFAULT_DAYS));
|
||||
if (to.isBefore(from)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.diary.dates", "The To date cannot be before the From date");
|
||||
}
|
||||
return this.diaryService.diary(from, to);
|
||||
}
|
||||
|
||||
private static LocalDate date(final String value, final LocalDate fallback) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim(), DATE);
|
||||
} catch (final DateTimeParseException e) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.diary.date.invalid", "Enter the date as DD-MM-YYYY", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fixed deposits placed with other banks. */
|
||||
public final class InvestmentFdApiConstants {
|
||||
|
||||
private InvestmentFdApiConstants() {}
|
||||
|
||||
public static final String ENTITY = "INVESTMENTFD";
|
||||
public static final String BOND_TYPE_FD = "FD";
|
||||
|
||||
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||
public static final String STATUS_MATURED = "MATURED";
|
||||
public static final String STATUS_PREMATURE = "PREMATURE";
|
||||
public static final String STATUS_RENEWED = "RENEWED";
|
||||
|
||||
/** When the interest is paid: all at maturity (the usual case), or every half year / year. */
|
||||
public static final String PAYOUT_AT_MATURITY = "MATURITY";
|
||||
public static final String PAYOUT_HALF_YEARLY = "HALF_YEARLY";
|
||||
public static final String PAYOUT_YEARLY = "YEARLY";
|
||||
public static final Set<String> PAYOUTS = Set.of(PAYOUT_AT_MATURITY, PAYOUT_HALF_YEARLY, PAYOUT_YEARLY);
|
||||
|
||||
/** How the maturity amount is worked out, to compare with the bank's figure (Jitendra Sir). */
|
||||
public static final String CALC_SIMPLE = "SIMPLE";
|
||||
public static final String CALC_MONTHLY = "MONTHLY";
|
||||
public static final String CALC_QUARTERLY = "QUARTERLY";
|
||||
public static final String CALC_HALF_YEARLY = "HALF_YEARLY";
|
||||
public static final String CALC_YEARLY = "YEARLY";
|
||||
public static final Set<String> CALC_TYPES = Set.of(CALC_SIMPLE, CALC_MONTHLY, CALC_QUARTERLY, CALC_HALF_YEARLY, CALC_YEARLY);
|
||||
|
||||
/** RBI: deposits with other banks within 20% of total deposits as on the previous 31 March, and within 5% with one bank. */
|
||||
public static final BigDecimal GROSS_LIMIT_PERCENT = BigDecimal.valueOf(20);
|
||||
public static final BigDecimal BANK_LIMIT_PERCENT = BigDecimal.valueOf(5);
|
||||
/** Current accounts with banks also count towards the 20% inter-bank exposure. */
|
||||
public static final String BANK_BALANCE_HEADER_GL_CODE = "100200";
|
||||
/** Deposits of the bank, for the suggested total-deposits figure: everything under the deposit header, plus RD. */
|
||||
public static final String DEPOSIT_HEADER_GL_CODE = "200100";
|
||||
public static final String RD_DEPOSIT_GL_CODE = "200201";
|
||||
|
||||
public static final String PARTY_ID = "partyId";
|
||||
public static final String CERT_NO = "certNo";
|
||||
public static final String CERT_DATE = "certDate";
|
||||
public static final String DEAL_DATE = "dealDate";
|
||||
public static final String AMOUNT = "amount";
|
||||
public static final String INT_RATE = "intRate";
|
||||
public static final String MATURITY_DATE = "maturityDate";
|
||||
public static final String PAYOUT = "payout";
|
||||
public static final String CALC_TYPE = "calcType";
|
||||
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
|
||||
public static final String REMARKS = "remarks";
|
||||
public static final String AS_ON_DATE = "asOnDate";
|
||||
public static final String TOTAL_DEPOSITS = "totalDeposits";
|
||||
public static final String OWNED_FUNDS = "ownedFunds";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
public static final String CLOSURE_MATURITY = "MATURITY";
|
||||
public static final String CLOSURE_PREMATURE = "PREMATURE";
|
||||
public static final String CLOSURE_RENEWAL = "RENEWAL";
|
||||
public static final String LINE_PROVISION = "PROVISION";
|
||||
public static final String LINE_PAYOUT = "PAYOUT";
|
||||
public static final String INTEREST_RECEIVABLE_GL_CODE = "100603";
|
||||
/** Tax the other bank deducts from the interest, recoverable from the tax department. */
|
||||
public static final String TDS_RECEIVABLE_GL_CODE = "100602";
|
||||
|
||||
public static final String CLOSE_DATE = "closeDate";
|
||||
public static final String RECEIVED_AMOUNT = "receivedAmount";
|
||||
public static final String TDS = "tds";
|
||||
public static final String PENAL_AMOUNT = "penalAmount";
|
||||
public static final String PRINCIPAL_BANK_GL_ACCOUNT_ID = "principalBankGlAccountId";
|
||||
public static final String INTEREST_BANK_GL_ACCOUNT_ID = "interestBankGlAccountId";
|
||||
public static final String NEW_CERT_NO = "newCertNo";
|
||||
public static final String NEW_CERT_DATE = "newCertDate";
|
||||
public static final String NEW_INT_RATE = "newIntRate";
|
||||
public static final String NEW_MATURITY_DATE = "newMaturityDate";
|
||||
public static final String NEW_PAYOUT = "newPayout";
|
||||
public static final String NEW_CALC_TYPE = "newCalcType";
|
||||
public static final String MATURITY_AMOUNT = "maturityAmount";
|
||||
public static final String RENEWAL_AMOUNT = "renewalAmount";
|
||||
public static final String DIFF_BANK_GL_ACCOUNT_ID = "diffBankGlAccountId";
|
||||
|
||||
public static final String LINE_MATURITY = "MATURITY";
|
||||
public static final String LINE_TYPE = "lineType";
|
||||
public static final String INT_DATE = "intDate";
|
||||
/** Reminders show lines due within this many days (as for Govt securities). */
|
||||
public static final int DUE_WITHIN_DAYS = 2;
|
||||
public static final Set<String> INTEREST_PARAMS = Set.of(LINE_TYPE, INT_DATE, RECEIVED_AMOUNT, TDS, BANK_GL_ACCOUNT_ID, LOCALE,
|
||||
DATE_FORMAT);
|
||||
|
||||
public static final Set<String> MATURE_PARAMS = Set.of(RECEIVED_AMOUNT, TDS, PRINCIPAL_BANK_GL_ACCOUNT_ID, INTEREST_BANK_GL_ACCOUNT_ID,
|
||||
REMARKS, LOCALE, DATE_FORMAT);
|
||||
public static final Set<String> PREMATURE_PARAMS = Set.of(CLOSE_DATE, RECEIVED_AMOUNT, TDS, PENAL_AMOUNT, PRINCIPAL_BANK_GL_ACCOUNT_ID,
|
||||
INTEREST_BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
|
||||
public static final Set<String> RENEW_PARAMS = Set.of(NEW_CERT_NO, NEW_CERT_DATE, NEW_INT_RATE, NEW_MATURITY_DATE, NEW_PAYOUT,
|
||||
NEW_CALC_TYPE, MATURITY_AMOUNT, RENEWAL_AMOUNT, TDS, DIFF_BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
|
||||
|
||||
public static final Set<String> FD_PARAMS = Set.of(PARTY_ID, CERT_NO, CERT_DATE, DEAL_DATE, AMOUNT, INT_RATE, MATURITY_DATE, PAYOUT,
|
||||
CALC_TYPE, BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
|
||||
public static final Set<String> BASE_PARAMS = Set.of(AS_ON_DATE, TOTAL_DEPOSITS, OWNED_FUNDS, REMARKS, LOCALE, DATE_FORMAT);
|
||||
}
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentFdClosureData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentFdData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentFdDueData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentFdClosureService;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentFdInterestService;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentFdService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/fds")
|
||||
@Component
|
||||
@Tag(name = "Investment FD", description = "Fixed deposits the bank places with other banks.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentFdApiResource {
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentFdService fdService;
|
||||
private final InvestmentFdClosureService closureService;
|
||||
private final InvestmentFdInterestService interestService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "All FDs placed with other banks")
|
||||
public List<InvestmentFdData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
|
||||
return this.fdService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("quote")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Preview an FD: interest, maturity amount and the RBI limits", description = "Dates dd-MM-yyyy")
|
||||
public InvestmentFdData quote(@QueryParam("partyId") final Long partyId, @QueryParam("amount") final BigDecimal amount,
|
||||
@QueryParam("intRate") final BigDecimal intRate, @QueryParam("dealDate") final String dealDate,
|
||||
@QueryParam("maturityDate") final String maturityDate, @QueryParam("payout") final String payout,
|
||||
@QueryParam("calcType") final String calcType) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
|
||||
return this.fdService.quote(partyId, amount, intRate, date(dealDate), date(maturityDate), payout, calcType);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("due")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "FD interest vouchers and maturities due", description = "Due now or within withinDays (default 2)")
|
||||
public List<InvestmentFdDueData> due(@QueryParam("withinDays") final Integer withinDays) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
|
||||
return this.interestService.due(withinDays == null ? InvestmentFdApiConstants.DUE_WITHIN_DAYS : withinDays);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{fdId}/interest-schedule")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Interest lines of an FD", description = "31 Mar / 30 Sep provisions, payouts and maturity")
|
||||
public InvestmentFdScheduleData schedule(@PathParam("fdId") final Long fdId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
|
||||
return this.interestService.schedule(fdId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{fdId}/interest")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Post the next interest line of an FD", description = "lineType PROVISION | PAYOUT, intDate; payout: receivedAmount, tds")
|
||||
public CommandProcessingResult postInterest(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
return this.commandsSourceWritePlatformService
|
||||
.logCommandSource(new CommandWrapperBuilder().postInvestmentFdInterest(fdId).withJson(apiRequestBodyAsJson).build());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{fdId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "One FD")
|
||||
public InvestmentFdData retrieveOne(@PathParam("fdId") @Parameter(description = "fdId") final Long fdId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
|
||||
return this.fdService.retrieveOne(fdId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Place an FD", description = "Dr the bank's FD GL / Cr our bank GL, dated the deal date; refused beyond the RBI limits.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentFd().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{fdId}/closure-quote")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Preview a maturity, premature closure or renewal", description = "type MATURITY | PREMATURE | RENEWAL")
|
||||
public InvestmentFdClosureData closureQuote(@PathParam("fdId") final Long fdId, @QueryParam("type") final String type,
|
||||
@QueryParam("closeDate") final String closeDate, @QueryParam("receivedAmount") final BigDecimal receivedAmount,
|
||||
@QueryParam("tds") final BigDecimal tds, @QueryParam("renewalAmount") final BigDecimal renewalAmount,
|
||||
@QueryParam("principalBankGlAccountId") final Long principalBankGlAccountId,
|
||||
@QueryParam("interestBankGlAccountId") final Long interestBankGlAccountId,
|
||||
@QueryParam("diffBankGlAccountId") final Long diffBankGlAccountId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
|
||||
return this.closureService.quote(fdId, type, date(closeDate), receivedAmount, tds, renewalAmount, principalBankGlAccountId,
|
||||
interestBankGlAccountId, diffBankGlAccountId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{fdId}/mature")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Close an FD at maturity", description = "Dated the maturity date; amount as credited by the bank, optional TDS.")
|
||||
public CommandProcessingResult mature(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
return this.commandsSourceWritePlatformService
|
||||
.logCommandSource(new CommandWrapperBuilder().matureInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{fdId}/premature")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Close an FD before maturity", description = "Not calculated: the amount received (and any penal interest) is entered.")
|
||||
public CommandProcessingResult premature(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
return this.commandsSourceWritePlatformService
|
||||
.logCommandSource(new CommandWrapperBuilder().prematureInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{fdId}/renew")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Renew an FD at maturity", description = "New certificate, rate and maturity; money added or taken out goes through a bank GL.")
|
||||
public CommandProcessingResult renew(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
return this.commandsSourceWritePlatformService
|
||||
.logCommandSource(new CommandWrapperBuilder().renewInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("deposit-base")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Record total deposits as on a 31 March", description = "The base of the RBI limits for the following year.")
|
||||
public CommandProcessingResult setDepositBase(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().setInvestmentDepositBase().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
private static LocalDate date(final String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim(), DATE);
|
||||
} catch (final java.time.format.DateTimeParseException e) {
|
||||
// A half-typed date is a bad request, not a server error.
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investmentfd.date.invalid", "Enter the date as DD-MM-YYYY", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Constants for posting an interest schedule line (coupon or closing provision) of a holding to GL. */
|
||||
public final class InvestmentInterestApiConstants {
|
||||
|
||||
private InvestmentInterestApiConstants() {}
|
||||
|
||||
public static final String ENTITY = "INVESTMENTINTEREST";
|
||||
public static final String RESOURCE = "investmentinterest";
|
||||
|
||||
public static final String LINE_TYPE = "lineType";
|
||||
public static final String INT_DATE = "intDate";
|
||||
/** Bank GL the coupon is received into; defaults to the purchase's paying bank. */
|
||||
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
public static final Set<String> PARAMS = Set.of(LINE_TYPE, INT_DATE, BANK_GL_ACCOUNT_ID, LOCALE, DATE_FORMAT);
|
||||
|
||||
/** A line shows as a task this many days before its date (Jitendra Sir). */
|
||||
public static final int DUE_WITHIN_DAYS = 2;
|
||||
|
||||
public static final String INTEREST_RECEIVABLE_GL_CODE = "100603";
|
||||
public static final String INTEREST_INCOME_GOVT_SECURITIES_GL_CODE = "400401";
|
||||
public static final String INTEREST_INCOME_BONDS_GL_CODE = "400402";
|
||||
|
||||
/** Income GL for a holding's interest: SLR govt securities, or bonds / debentures for the rest. */
|
||||
public static String interestIncomeGlCode(final String bondType) {
|
||||
return "GOVT_SECURITY".equals(bondType) ? INTEREST_INCOME_GOVT_SECURITIES_GL_CODE : INTEREST_INCOME_BONDS_GL_CODE;
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentInterestDueData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentInterestPostingData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/interest-postings")
|
||||
@Component
|
||||
@Tag(name = "Investment Interest Posting", description = "Interest lines of holdings that are due to be posted with a voucher.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentInterestApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentPurchaseReadPlatformService readPlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Posted interest lines", description = "Coupons and closing provisions already posted to GL with their "
|
||||
+ "voucher, newest first.")
|
||||
public List<InvestmentInterestPostingData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentInterestApiConstants.ENTITY);
|
||||
return this.readPlatformService.retrieveInterestPostings();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("due")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Interest lines due", description = "For every active holding, its next unposted interest line when that "
|
||||
+ "line's date is past or within withinDays days (default 2).")
|
||||
public List<InvestmentInterestDueData> due(@QueryParam("withinDays") @Parameter(description = "withinDays") final Integer withinDays) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentInterestApiConstants.ENTITY);
|
||||
return this.readPlatformService
|
||||
.retrieveDueInterest(withinDays == null ? InvestmentInterestApiConstants.DUE_WITHIN_DAYS : withinDays);
|
||||
}
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Month;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.format.ResolverStyle;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.investment.data.InvestmentInterestReportData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentInterestReportService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/interest-report")
|
||||
@Component
|
||||
@Tag(name = "Investment Interest Received & Receivable", description = "Interest received in a period and receivable at its end.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentInterestReportApiResource {
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(ResolverStyle.STRICT);
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentInterestReportService reportService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Total Interest Received & Receivable", description = "fromDate / toDate dd-MM-yyyy; default 1 April of the "
|
||||
+ "current financial year to today")
|
||||
public InvestmentInterestReportData report(@QueryParam("fromDate") @Parameter(description = "dd-MM-yyyy") final String fromDate,
|
||||
@QueryParam("toDate") @Parameter(description = "dd-MM-yyyy") final String toDate) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate to = date(toDate, today);
|
||||
if (to.isAfter(today)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.future", "The To date cannot be after today");
|
||||
}
|
||||
// Without a From date the report runs from 1 April of the To date's financial year.
|
||||
final LocalDate fyStart = LocalDate.of(to.getMonthValue() >= 4 ? to.getYear() : to.getYear() - 1, Month.APRIL, 1);
|
||||
final LocalDate from = date(fromDate, fyStart);
|
||||
if (to.isBefore(from)) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.dates", "The To date cannot be before the From date");
|
||||
}
|
||||
return this.reportService.report(from, to);
|
||||
}
|
||||
|
||||
private static LocalDate date(final String value, final LocalDate fallback) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim(), DATE);
|
||||
} catch (final DateTimeParseException e) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.date.invalid", "Enter the date as DD-MM-YYYY", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/parties")
|
||||
@Component
|
||||
@Tag(name = "Investment Bank / Party Master", description = "Counterparties an investment is bought from or placed with.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentPartyApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentMasterReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List investment parties")
|
||||
public List<InvestmentPartyData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
|
||||
return this.readPlatformService.retrieveAllParties();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{partyId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve an investment party")
|
||||
public InvestmentPartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
|
||||
return this.readPlatformService.retrieveParty(partyId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create an investment party")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentParty().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{partyId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update an investment party")
|
||||
public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentParty(partyId).withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{partyId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Drop an investment party")
|
||||
public CommandProcessingResult delete(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentParty(partyId).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Constants for the Investment Purchase Entry (a deal that creates a holding). */
|
||||
public final class InvestmentPurchaseApiConstants {
|
||||
|
||||
private InvestmentPurchaseApiConstants() {}
|
||||
|
||||
public static final String ENTITY = "INVESTMENTPURCHASE";
|
||||
public static final String RESOURCE = "investmentpurchase";
|
||||
|
||||
public static final Set<String> SETTLEMENT_TYPES = Set.of("T+0", "T+1", "T+2");
|
||||
public static final Set<String> CATEGORIES = Set.of("HTM (Held To Maturity)", "AFS (Available For Sale)", "HFT (Held For Trading)");
|
||||
|
||||
/** Saved deal; nothing posted to GL until it is settled. */
|
||||
public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
|
||||
/** Settled: posted to GL on the settlement date, now a holding. */
|
||||
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||
/** Every unit sold before maturity. */
|
||||
public static final String STATUS_SOLD = "SOLD";
|
||||
/** Repaid at maturity. */
|
||||
public static final String STATUS_REDEEMED = "REDEEMED";
|
||||
/** Dropped before settlement; kept so its deal and certificate numbers are never given out again. */
|
||||
public static final String STATUS_DROPPED = "DROPPED";
|
||||
|
||||
/** GLs the settlement entry debits besides the script's investment GL. */
|
||||
public static final String BROKEN_PERIOD_INTEREST_GL_CODE = "500606";
|
||||
public static final String STAMP_DUTY_GL_CODE = "500601";
|
||||
public static final String BROKERAGE_GL_CODE = "500602";
|
||||
public static final String OTHER_CHARGES_GL_CODE = "500603";
|
||||
|
||||
public static final String BOND_TYPE = "bondType";
|
||||
public static final String SCRIPT_ID = "scriptId";
|
||||
public static final String PARTY_ID = "partyId";
|
||||
public static final String DEAL_DATE = "dealDate";
|
||||
public static final String DEAL_TIME = "dealTime";
|
||||
public static final String SETTLEMENT_TYPE = "settlementType";
|
||||
public static final String FACE_VALUE = "faceValue";
|
||||
public static final String PURCHASE_RATE = "purchaseRate";
|
||||
public static final String CONSIDER_AMT = "considerAmt";
|
||||
public static final String NAV = "nav";
|
||||
public static final String STAMP_DUTY = "stampDuty";
|
||||
public static final String BROKERAGE = "brokerage";
|
||||
public static final String OTHER_CHARGES = "otherCharges";
|
||||
public static final String CATEGORY = "category";
|
||||
public static final String LAST_INT_DATE = "lastIntDate";
|
||||
public static final String CR_GL_ACCOUNT_ID = "crGlAccountId";
|
||||
public static final String SUB_TYPE = "subType";
|
||||
/** Optional legacy fields (trade no, authorities, lien, delivery mode, …) kept as a JSON object. */
|
||||
public static final String OTHER_DETAILS = "otherDetails";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
|
||||
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, OTHER_CHARGES, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE,
|
||||
OTHER_DETAILS, LOCALE, DATE_FORMAT);
|
||||
|
||||
/** Dividend received on a share holding: Dr Bank / Cr 400404 Dividend on Investments, dated the day it is received. */
|
||||
public static final String DIVIDEND_GL_CODE = "400404";
|
||||
public static final String DIVIDEND_DATE = "dividendDate";
|
||||
public static final String AMOUNT = "amount";
|
||||
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
|
||||
public static final String REMARKS = "remarks";
|
||||
public static final Set<String> DIVIDEND_PARAMS = Set.of(DIVIDEND_DATE, AMOUNT, BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
|
||||
}
|
||||
-252
@@ -1,252 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentDividendData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentShareLimitData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentNonSlrService;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentShareService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/purchases")
|
||||
@Component
|
||||
@Tag(name = "Investment Purchase Entry", description = "Investment deals (Govt Security, Bond, Mutual Fund, …) — each becomes a holding.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentPurchaseApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentPurchaseReadPlatformService readPlatformService;
|
||||
private final InvestmentSaleReadPlatformService saleReadPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final InvestmentNonSlrService nonSlrService;
|
||||
private final InvestmentShareService shareService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List investment purchases (holdings)")
|
||||
public List<InvestmentPurchaseData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
return this.readPlatformService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("non-slr-limit")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "RBI non-SLR limits for a purchase", description = "Where a non-SLR purchase of amount (book value) in scriptId "
|
||||
+ "on dealDate (dd-MM-yyyy, default today) stands against the 10% limits; any of them may be left out.")
|
||||
public InvestmentNonSlrLimitData nonSlrLimit(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
|
||||
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
|
||||
@QueryParam("amount") @Parameter(description = "amount") final BigDecimal amount) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
final LocalDate date = InvestmentApiDates.parse(dealDate, DateUtils.getBusinessLocalDate(), "deal date");
|
||||
return this.nonSlrService.limits(date, scriptId, amount);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("share-limit")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "RBI limits for a share purchase", description = "Where a purchase of amount (book value) of shares of partyId "
|
||||
+ "on dealDate (dd-MM-yyyy, default today) stands against the 2% of owned funds and 5% of the society's capital limits; "
|
||||
+ "membership shares are exempt.")
|
||||
public InvestmentShareLimitData shareLimit(@QueryParam("partyId") @Parameter(description = "partyId") final Long partyId,
|
||||
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
|
||||
@QueryParam("amount") @Parameter(description = "amount") final BigDecimal amount) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
final LocalDate date = InvestmentApiDates.parse(dealDate, DateUtils.getBusinessLocalDate(), "deal date");
|
||||
return this.shareService.limits(date, partyId, amount);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("dividends")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Dividends received on share holdings", description = "Newest first, with their vouchers.")
|
||||
public List<InvestmentDividendData> retrieveDividends() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
return this.shareService.retrieveDividends();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{purchaseId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve an investment purchase")
|
||||
public InvestmentPurchaseData retrieveOne(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
return this.readPlatformService.retrieveOne(purchaseId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{purchaseId}/interest-schedule")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Interest schedule of a holding", description = "Half-yearly coupons and 31 Mar / 30 Sep closing provisions "
|
||||
+ "from purchase to maturity. Calculated only — nothing is posted. asOnDate (dd-MM-yyyy) defaults to the business date.")
|
||||
public InvestmentInterestScheduleData retrieveInterestSchedule(
|
||||
@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
|
||||
final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null : InvestmentApiDates.parse(asOnDate, null, "as-on date");
|
||||
return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Save a purchase entry")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentPurchase().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{purchaseId}/settle")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Settle a purchase", description = "On or after the settlement date: posts the purchase to GL dated the "
|
||||
+ "settlement date and makes the deal an active holding.")
|
||||
public CommandProcessingResult settle(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().settleInvestmentPurchase(purchaseId)
|
||||
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{purchaseId}/interest-postings")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Post an interest line", description = "Posts the next interest schedule line (coupon or closing provision) "
|
||||
+ "of a holding to GL with a JV voucher, on or after its date and in date order.")
|
||||
public CommandProcessingResult postInterest(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().postInvestmentInterest(purchaseId).withJson(apiRequestBodyAsJson)
|
||||
.build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{purchaseId}/redemption-quote")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "What the redemption at maturity will post", description = "Face value back against the book value still "
|
||||
+ "held; refused before maturity or while a sale is pending.")
|
||||
public InvestmentSaleData redemptionQuote(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
|
||||
return this.saleReadPlatformService.quoteRedemption(purchaseId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{purchaseId}/redeem")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Redeem at maturity", description = "On or after maturity, once every coupon is posted: posts the face value "
|
||||
+ "received, dated the maturity date, and closes the holding.")
|
||||
public CommandProcessingResult redeem(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().redeemInvestmentPurchase(purchaseId)
|
||||
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{purchaseId}/write-off")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Write off part of the book value", description = "Dr Loss on Investments / Cr Investment for the amount "
|
||||
+ "given; the units stay held at the reduced book value.")
|
||||
public CommandProcessingResult writeOff(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().writeOffInvestmentPurchase(purchaseId).withJson(apiRequestBodyAsJson)
|
||||
.build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{purchaseId}/dividends")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Record a dividend received on a share holding", description = "dividendDate, amount, bankGlAccountId, "
|
||||
+ "remarks: Dr the bank GL / Cr 400404 Dividend on Investments, dated the day it was received.")
|
||||
public CommandProcessingResult postDividend(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().dividendInvestmentPurchase(purchaseId).withJson(apiRequestBodyAsJson)
|
||||
.build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{purchaseId}/transfer")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "GOI Category Transfer", description = "Moves the holding to another security (scriptId) of the same coupon "
|
||||
+ "rate, maturity and unit value; the book value moves between GLs only when the two scripts use different GLs.")
|
||||
public CommandProcessingResult transfer(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().transferInvestmentSecurity(purchaseId).withJson(apiRequestBodyAsJson)
|
||||
.build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{purchaseId}/switch")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Mutual fund switch", description = "switchDate, units, fromNav, toScriptId, toNav, subType, remarks: the units "
|
||||
+ "leave this fund at fromNav and a new deal opens in the other scheme the same day; no bank leg.")
|
||||
public CommandProcessingResult switchFund(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().switchInvestmentFund(purchaseId).withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{purchaseId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Drop a purchase entry")
|
||||
public CommandProcessingResult delete(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentPurchase(purchaseId).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Sale (before maturity) and redemption (at maturity) of investment holdings. */
|
||||
public final class InvestmentSaleApiConstants {
|
||||
|
||||
private InvestmentSaleApiConstants() {}
|
||||
|
||||
public static final String ENTITY = "INVESTMENTSALE";
|
||||
public static final String RESOURCE = "investmentsale";
|
||||
|
||||
public static final String TYPE_SELL = "SELL";
|
||||
public static final String TYPE_REDEEM = "REDEEM";
|
||||
/** Part of the book value written off as a loss; the units stay held. */
|
||||
public static final String TYPE_WRITEOFF = "WRITEOFF";
|
||||
/** A year-end amortisation of premium (31 March); recorded like a sale so the book value comes down. */
|
||||
public static final String TYPE_AMORTISE = "AMORTISE";
|
||||
/** Units of a mutual fund switched at NAV into another scheme the same day; no money comes to the bank. */
|
||||
public static final String TYPE_SWITCH = "SWITCH";
|
||||
|
||||
public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
|
||||
public static final String STATUS_SETTLED = "SETTLED";
|
||||
|
||||
/** Profit on sale / redemption (income) and loss on sale / redemption (expense). */
|
||||
public static final String PROFIT_ON_SALE_GL_CODE = "400405";
|
||||
public static final String LOSS_ON_SALE_GL_CODE = "500605";
|
||||
/** Premium still on the books at maturity is written off to the amortisation expense. */
|
||||
public static final String PREMIUM_AMORTISATION_GL_CODE = "500604";
|
||||
/** Premium paid over face value, held here until amortised (Jitendra Sir). */
|
||||
public static final String PREMIUM_ON_INVESTMENTS_GL_CODE = "100707";
|
||||
|
||||
public static final String PURCHASE_ID = "purchaseId";
|
||||
public static final String PARTY_ID = "partyId";
|
||||
public static final String DEAL_DATE = "dealDate";
|
||||
public static final String DEAL_TIME = "dealTime";
|
||||
public static final String SETTLEMENT_TYPE = "settlementType";
|
||||
public static final String UNITS = "units";
|
||||
public static final String SALE_RATE = "saleRate";
|
||||
public static final String BROKERAGE = "brokerage";
|
||||
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
|
||||
public static final String SANCTION_AUTHORITY = "sanctionAuthority";
|
||||
public static final String REMARKS = "remarks";
|
||||
public static final String AMOUNT = "amount";
|
||||
public static final String FY_END = "fyEnd";
|
||||
public static final String SWITCH_DATE = "switchDate";
|
||||
public static final String FROM_NAV = "fromNav";
|
||||
public static final String TO_SCRIPT_ID = "toScriptId";
|
||||
public static final String TO_NAV = "toNav";
|
||||
public static final String SUB_TYPE = "subType";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
public static final Set<String> SELL_PARAMS = Set.of(PURCHASE_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, UNITS, SALE_RATE,
|
||||
BROKERAGE, BANK_GL_ACCOUNT_ID, SANCTION_AUTHORITY, REMARKS, LOCALE, DATE_FORMAT);
|
||||
public static final Set<String> REDEEM_PARAMS = Set.of(BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
|
||||
public static final Set<String> WRITEOFF_PARAMS = Set.of(AMOUNT, REMARKS, LOCALE, DATE_FORMAT);
|
||||
public static final Set<String> AMORTISE_PARAMS = Set.of(PURCHASE_ID, FY_END, LOCALE, DATE_FORMAT);
|
||||
public static final Set<String> SWITCH_PARAMS = Set.of(SWITCH_DATE, UNITS, FROM_NAV, TO_SCRIPT_ID, TO_NAV, SUB_TYPE, REMARKS, LOCALE,
|
||||
DATE_FORMAT);
|
||||
}
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/sales")
|
||||
@Component
|
||||
@Tag(name = "Investment Sale", description = "Sale of holdings before maturity (all or part of the units), settled on the settlement date.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentSaleApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentSaleReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List sales and redemptions, newest first")
|
||||
public List<InvestmentSaleData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
|
||||
return this.readPlatformService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("quote")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Work out a sale before saving it", description = "Price, interest from the buyer, cost, profit or loss "
|
||||
+ "and the net amount for the units, rate and deal date given (dd-MM-yyyy).")
|
||||
public InvestmentSaleData quote(@QueryParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
|
||||
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
|
||||
@QueryParam("settlementType") @Parameter(description = "T+0 / T+1 / T+2") final String settlementType,
|
||||
@QueryParam("units") @Parameter(description = "units") final BigDecimal units,
|
||||
@QueryParam("saleRate") @Parameter(description = "saleRate") final BigDecimal saleRate,
|
||||
@QueryParam("brokerage") @Parameter(description = "brokerage") final BigDecimal brokerage,
|
||||
@QueryParam("bankGlAccountId") @Parameter(description = "bankGlAccountId") final Long bankGlAccountId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
|
||||
final LocalDate deal = InvestmentApiDates.parse(dealDate, null, "deal date");
|
||||
return this.readPlatformService.quoteSale(purchaseId, deal, StringUtils.defaultIfBlank(settlementType, "T+0"), units, saleRate,
|
||||
brokerage, bankGlAccountId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Save a sale", description = "Saved pending; nothing reaches the GL until it is settled.")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentSale().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{saleId}/settle")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Settle a sale", description = "On or after the settlement date: posts the sale to GL dated the settlement "
|
||||
+ "date and reduces the units held.")
|
||||
public CommandProcessingResult settle(@PathParam("saleId") @Parameter(description = "saleId") final Long saleId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().settleInvestmentSale(saleId)
|
||||
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{saleId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Drop a pending sale")
|
||||
public CommandProcessingResult delete(@PathParam("saleId") @Parameter(description = "saleId") final Long saleId) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentSale(saleId).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/scripts")
|
||||
@Component
|
||||
@Tag(name = "Investment Script Master", description = "Securities / instruments the bank invests in (Govt Security, Bond, Mutual Fund, …).")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentScriptApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentMasterReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List investment scripts")
|
||||
public List<InvestmentScriptData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
|
||||
return this.readPlatformService.retrieveAllScripts();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{scriptId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve an investment script")
|
||||
public InvestmentScriptData retrieveOne(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
|
||||
return this.readPlatformService.retrieveScript(scriptId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Create an investment script")
|
||||
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentScript().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{scriptId}")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Update an investment script")
|
||||
public CommandProcessingResult update(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentScript(scriptId).withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentSecurityTransferData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentSecurityTransferService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/transfers")
|
||||
@Component
|
||||
@Tag(name = "Investment Security Transfer", description = "GOI Category Transfer: a holding moved to another security of the same terms.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentTransferApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentSecurityTransferService transferService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List security transfers, newest first")
|
||||
public List<InvestmentSecurityTransferData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission("INVESTMENTTRANSFER");
|
||||
return this.transferService.retrieveAll();
|
||||
}
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Market valuation (MTM) of AFS and HFT holdings, with net depreciation held in the Investment Depreciation Reserve. */
|
||||
public final class InvestmentValuationApiConstants {
|
||||
|
||||
private InvestmentValuationApiConstants() {}
|
||||
|
||||
public static final String ENTITY = "INVESTMENTVALUATION";
|
||||
public static final String RESOURCE = "investmentvaluation";
|
||||
|
||||
/** Categories that are marked to market: HFT monthly, AFS quarterly (or more often). HTM is carried at cost. */
|
||||
public static final Set<String> CATEGORIES = Set.of("AFS", "HFT");
|
||||
|
||||
/** Classes within which gains and losses are netted; one class never offsets another. */
|
||||
public static final String CLASS_GOVT = "GOVT_SECURITY";
|
||||
public static final String CLASS_BOND = "CORPORATE_BOND";
|
||||
public static final String CLASS_FUND = "MUTUAL_FUND";
|
||||
/** Shares are valued by rule, not at a market rate: face value, nil, or ₹1 per institution (see the valuation service). */
|
||||
public static final String CLASS_SHARE = "SHARE";
|
||||
|
||||
/** Depreciation charged to P&L (Provisions & Contingencies) and the reserve it is held in. */
|
||||
public static final String DEPRECIATION_PROVISION_GL_CODE = "500504";
|
||||
public static final String DEPRECIATION_RESERVE_GL_CODE = "200205";
|
||||
|
||||
public static final String VALUATION_DATE = "valuationDate";
|
||||
public static final String CATEGORY = "category";
|
||||
public static final String LOCALE = "locale";
|
||||
public static final String DATE_FORMAT = "dateFormat";
|
||||
|
||||
public static final Set<String> RUN_PARAMS = Set.of(VALUATION_DATE, CATEGORY, LOCALE, DATE_FORMAT);
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 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.investment.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentValuationData;
|
||||
import org.apache.fineract.portfolio.investment.service.InvestmentValuationService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/investment/valuations")
|
||||
@Component
|
||||
@Tag(name = "Investment Valuation", description = "Market valuation (MTM) of AFS / HFT holdings; net depreciation held in the IDR.")
|
||||
@RequiredArgsConstructor
|
||||
public class InvestmentValuationApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final InvestmentValuationService valuationService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Valuation runs, newest first")
|
||||
public List<InvestmentValuationData> retrieveAll() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentValuationApiConstants.ENTITY);
|
||||
return this.valuationService.retrieveAll();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("preview")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "What a valuation would find and post, without saving", description = "valuationDate dd-MM-yyyy (default "
|
||||
+ "today), category AFS or HFT")
|
||||
public InvestmentValuationData preview(@QueryParam("valuationDate") @Parameter(description = "dd-MM-yyyy") final String valuationDate,
|
||||
@QueryParam("category") @Parameter(description = "AFS or HFT") final String category) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentValuationApiConstants.ENTITY);
|
||||
return this.valuationService.preview(InvestmentApiDates.parse(valuationDate, DateUtils.getBusinessLocalDate(), "valuation date"),
|
||||
category);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{valuationId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "One valuation run with its security-wise lines")
|
||||
public InvestmentValuationData retrieveOne(@PathParam("valuationId") @Parameter(description = "valuationId") final Long valuationId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentValuationApiConstants.ENTITY);
|
||||
return this.valuationService.retrieveOne(valuationId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Run and post a valuation", description = "valuationDate, category (AFS / HFT). Posts the change in IDR: "
|
||||
+ "Dr 500504 / Cr 200205 when it grows, the reverse when it falls.")
|
||||
public CommandProcessingResult run(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentValuation().withJson(apiRequestBodyAsJson).build();
|
||||
return this.commandsSourceWritePlatformService.logCommandSource(request);
|
||||
}
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** Premium amortisation schedule of an HTM holding: one line per 31 March to maturity, then the rest at maturity. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentAmortisationData {
|
||||
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final String category;
|
||||
private final String settlementDate;
|
||||
private final String maturityDate;
|
||||
private final BigDecimal faceValue;
|
||||
private final BigDecimal premium;
|
||||
private final BigDecimal unamortisedPremium;
|
||||
private final BigDecimal bookValue;
|
||||
private final String premiumGlCode;
|
||||
private final List<Row> rows;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Row {
|
||||
|
||||
/** YEAR (31 March) or MATURITY (the remainder, written off on redemption). */
|
||||
private final String kind;
|
||||
private final String fyEnd;
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
private final long days;
|
||||
private final BigDecimal amount;
|
||||
private final BigDecimal premiumAfter;
|
||||
private final boolean posted;
|
||||
private final String voucherNo;
|
||||
private final String glTransactionId;
|
||||
/** Next line to post and its 31 March has come. */
|
||||
private final boolean postable;
|
||||
}
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** A holding's premium amortisation due for a 31 March (Amortize Yearly Process). */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentAmortisationDueData {
|
||||
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final String fyEnd;
|
||||
private final String fromDate;
|
||||
private final long days;
|
||||
private final BigDecimal unamortisedPremium;
|
||||
private final BigDecimal amount;
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Bucket Wise Investment: Govt Securities, bonds and FDs held on a date, split by the time left to maturity (up to 1
|
||||
* year, over 1 to 3, over 3 to 5, over 5 years).
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentBucketReportData {
|
||||
|
||||
private final String asOnDate;
|
||||
private final List<Bucket> buckets;
|
||||
private final Bucket total;
|
||||
private final List<Row> rows;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Bucket {
|
||||
|
||||
private final String code;
|
||||
private final String label;
|
||||
private final int govtCount;
|
||||
private final BigDecimal govtFaceValue;
|
||||
private final BigDecimal govtBookValue;
|
||||
private final int bondCount;
|
||||
private final BigDecimal bondFaceValue;
|
||||
private final BigDecimal bondBookValue;
|
||||
private final int fdCount;
|
||||
private final BigDecimal fdPrincipal;
|
||||
/** Book value of securities and bonds plus FD principal. */
|
||||
private final BigDecimal totalAmount;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Row {
|
||||
|
||||
/** Code of the bucket the holding falls in. */
|
||||
private final String bucket;
|
||||
/** GOVT_SECURITY, BOND (Non-SLR and other bonds) or FD. */
|
||||
private final String instrument;
|
||||
private final Long refId;
|
||||
private final String dealNo;
|
||||
/** Script, or bank and certificate of an FD. */
|
||||
private final String particulars;
|
||||
/** HTM / AFS / HFT of a security; blank for an FD. */
|
||||
private final String category;
|
||||
private final BigDecimal rate;
|
||||
private final String maturityDate;
|
||||
/** Days from the as-on date to maturity; negative when maturity has passed but it is not yet redeemed or closed. */
|
||||
private final long daysToMaturity;
|
||||
/** Face value held, or the FD principal. */
|
||||
private final BigDecimal faceValue;
|
||||
/** Cost less what sales, amortisation and write-offs took off, or the FD principal. */
|
||||
private final BigDecimal bookValue;
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentDailyRateData {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final Long id;
|
||||
private final Long scriptId;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final String bondType;
|
||||
private final String rateDate;
|
||||
private final BigDecimal openRate;
|
||||
private final BigDecimal highRate;
|
||||
private final BigDecimal lowRate;
|
||||
private final BigDecimal closeRate;
|
||||
|
||||
public static InvestmentDailyRateData from(final InvestmentDailyRate r) {
|
||||
return InvestmentDailyRateData.builder().id(r.getId()).scriptId(r.getScript().getId()).scriptCode(r.getScript().getScriptCode())
|
||||
.scriptDesc(r.getScript().getScriptDesc()).bondType(r.getScript().getBondType())
|
||||
.rateDate(r.getRateDate().format(DATE_FORMAT)).openRate(r.getOpenRate()).highRate(r.getHighRate()).lowRate(r.getLowRate())
|
||||
.closeRate(r.getCloseRate()).build();
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Due Date Diary: every dated event of the active investments in a period — Govt Security / bond coupons and 31 Mar /
|
||||
* 30 Sep provisions, maturities, premium amortisation, and FD provisions, payouts and maturities.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentDiaryData {
|
||||
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
private final String asOnDate;
|
||||
private final int pendingCount;
|
||||
private final int overdueCount;
|
||||
/** Money expected in the period from coupons, FD payouts and maturities not yet received. */
|
||||
private final BigDecimal expectedInflow;
|
||||
private final List<Row> rows;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Row {
|
||||
|
||||
private final String date;
|
||||
/** COUPON, PROVISION, MATURITY, AMORTISATION (Govt Security / bond); FD_PROVISION, FD_PAYOUT, FD_MATURITY. */
|
||||
private final String kind;
|
||||
/** SECURITY or FD — which screen the entry belongs to. */
|
||||
private final String instrument;
|
||||
private final Long refId;
|
||||
private final String dealNo;
|
||||
/** Script, or bank and certificate of an FD. */
|
||||
private final String particulars;
|
||||
private final BigDecimal amount;
|
||||
/** POSTED, OVERDUE, DUE (today), UPCOMING. */
|
||||
private final String status;
|
||||
private final String voucherNo;
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentDividend;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
|
||||
|
||||
/** A dividend received on a share holding, with its voucher. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentDividendData {
|
||||
|
||||
private final Long id;
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final Long partyId;
|
||||
private final String partyName;
|
||||
private final String dividendDate;
|
||||
private final BigDecimal amount;
|
||||
private final String bankGlCode;
|
||||
private final String bankGlName;
|
||||
private final String remarks;
|
||||
private final String glTransactionId;
|
||||
private final String voucherNo;
|
||||
|
||||
public static InvestmentDividendData from(final InvestmentDividend d) {
|
||||
final InvestmentPurchase p = d.getPurchase();
|
||||
return InvestmentDividendData.builder().id(d.getId()).purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
|
||||
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).partyId(p.getParty().getId())
|
||||
.partyName(p.getParty().getPartyName()).dividendDate(d.getDividendDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")))
|
||||
.amount(d.getAmount()).bankGlCode(d.getBankGlAccount().getGlCode()).bankGlName(d.getBankGlAccount().getName())
|
||||
.remarks(d.getRemarks()).glTransactionId(d.getGlTransactionId()).voucherNo(d.getVoucherNo()).build();
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** What closing an FD (maturity, premature or renewal) works out to, and the voucher it posts. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentFdClosureData {
|
||||
|
||||
private final Long fdId;
|
||||
private final String dealNo;
|
||||
private final String type;
|
||||
private final String closeDate;
|
||||
private final BigDecimal principal;
|
||||
/** Interest the system works out for the period, to compare with the bank's figure. */
|
||||
private final BigDecimal expectedInterest;
|
||||
/** Interest already booked by provisions and not yet received. */
|
||||
private final BigDecimal provisioned;
|
||||
/** Money credited to our bank by the other bank (net of TDS). */
|
||||
private final BigDecimal receivedAmount;
|
||||
private final BigDecimal tds;
|
||||
private final BigDecimal penalAmount;
|
||||
/** Gross interest as per the bank: received + TDS − principal. */
|
||||
private final BigDecimal interest;
|
||||
/** Interest income of this entry: interest less what was provisioned (negative = reversed from income). */
|
||||
private final BigDecimal income;
|
||||
/** Bank's interest less the system's. */
|
||||
private final BigDecimal difference;
|
||||
private final BigDecimal renewalAmount;
|
||||
/** Renewal: money added (positive) or taken out (negative) against the maturity amount. */
|
||||
private final BigDecimal topUp;
|
||||
private final List<Line> lines;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public static class Line {
|
||||
|
||||
private final String side;
|
||||
private final String glCode;
|
||||
private final String glName;
|
||||
private final BigDecimal amount;
|
||||
}
|
||||
}
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentFd;
|
||||
|
||||
/** An FD placed with another bank; also the shape of a purchase preview (with the limit check). */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentFdData {
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final Long id;
|
||||
private final String dealNo;
|
||||
private final Long partyId;
|
||||
private final String partyCode;
|
||||
private final String partyName;
|
||||
/** TDS rate the bank deducts on interest; null when it does not deduct TDS. */
|
||||
private final BigDecimal tdsRate;
|
||||
private final String certNo;
|
||||
private final String certDate;
|
||||
private final String dealDate;
|
||||
private final BigDecimal principal;
|
||||
private final BigDecimal intRate;
|
||||
private final String maturityDate;
|
||||
private final long days;
|
||||
private final String payout;
|
||||
private final String calcType;
|
||||
private final BigDecimal expectedInterest;
|
||||
private final BigDecimal expectedMaturityAmount;
|
||||
/** Interest paid each half year / year for a payout FD. */
|
||||
private final BigDecimal periodInterest;
|
||||
private final String fdGlCode;
|
||||
private final String fdGlName;
|
||||
private final String interestGlCode;
|
||||
private final String interestGlName;
|
||||
private final Long bankGlAccountId;
|
||||
private final String bankGlCode;
|
||||
private final String bankGlName;
|
||||
private final String status;
|
||||
private final String remarks;
|
||||
private final String glTransactionId;
|
||||
private final String voucherNo;
|
||||
private final String renewedFromDealNo;
|
||||
private final String closedOnDate;
|
||||
private final String closureType;
|
||||
private final BigDecimal closureAmount;
|
||||
private final BigDecimal closureInterest;
|
||||
private final BigDecimal closureTds;
|
||||
private final BigDecimal closurePenal;
|
||||
private final String closureVoucherNo;
|
||||
private final String closureGlTransactionId;
|
||||
private final InvestmentFdLimitData limit;
|
||||
|
||||
public static InvestmentFdData from(final InvestmentFd f, final BigDecimal periodInterest) {
|
||||
return builder().id(f.getId()).dealNo(f.getDealNo()).partyId(f.getParty().getId()).partyCode(f.getParty().getPartyCode())
|
||||
.partyName(f.getParty().getPartyName())
|
||||
.tdsRate(f.getParty().isTdsApplicable() ? f.getParty().getTdsRate() : null).certNo(f.getCertNo())
|
||||
.certDate(format(f.getCertDate()))
|
||||
.dealDate(format(f.getDealDate())).principal(f.getPrincipal()).intRate(f.getIntRate())
|
||||
.maturityDate(format(f.getMaturityDate())).days(ChronoUnit.DAYS.between(f.getDealDate(), f.getMaturityDate()))
|
||||
.payout(f.getPayout()).calcType(f.getCalcType()).expectedInterest(f.getExpectedInterest())
|
||||
.expectedMaturityAmount(f.getExpectedMaturityAmount()).periodInterest(periodInterest).fdGlCode(code(f.getFdGlAccount()))
|
||||
.fdGlName(name(f.getFdGlAccount())).interestGlCode(code(f.getInterestGlAccount())).interestGlName(name(f.getInterestGlAccount()))
|
||||
.bankGlAccountId(f.getBankGlAccount().getId()).bankGlCode(code(f.getBankGlAccount())).bankGlName(name(f.getBankGlAccount()))
|
||||
.status(f.getStatus()).remarks(f.getRemarks()).glTransactionId(f.getGlTransactionId()).voucherNo(f.getVoucherNo())
|
||||
.renewedFromDealNo(f.getRenewedFrom() == null ? null : f.getRenewedFrom().getDealNo()).closedOnDate(format(f.getClosedOnDate()))
|
||||
.closureType(f.getClosureType()).closureAmount(f.getClosureAmount()).closureInterest(f.getClosureInterest())
|
||||
.closureTds(f.getClosureTds()).closurePenal(f.getClosurePenal()).closureVoucherNo(f.getClosureVoucherNo()).closureGlTransactionId(f.getClosureGlTransactionId()).build();
|
||||
}
|
||||
|
||||
private static String code(final GLAccount gl) {
|
||||
return gl == null ? null : gl.getGlCode();
|
||||
}
|
||||
|
||||
private static String name(final GLAccount gl) {
|
||||
return gl == null ? null : gl.getName();
|
||||
}
|
||||
|
||||
private static String format(final LocalDate date) {
|
||||
return date == null ? null : date.format(DATE);
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** The next interest voucher (or the maturity) of an FD that is due, or due within the reminder window. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentFdDueData {
|
||||
|
||||
private final Long fdId;
|
||||
private final String dealNo;
|
||||
private final String partyName;
|
||||
private final String certNo;
|
||||
/** PROVISION, PAYOUT or MATURITY. */
|
||||
private final String lineType;
|
||||
private final String intDate;
|
||||
/** What the bank credits (net of TDS); a provision line: what it books. */
|
||||
private final BigDecimal amount;
|
||||
private final BigDecimal grossInterest;
|
||||
private final BigDecimal tds;
|
||||
private final boolean postable;
|
||||
private final boolean overdue;
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** Where a new FD stands against the RBI limits on deposits with other banks. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentFdLimitData {
|
||||
|
||||
/** The 31 March the limits are based on. */
|
||||
private final String asOnDate;
|
||||
/** Total deposits as on that date, as entered; empty until someone enters it. */
|
||||
private final BigDecimal totalDeposits;
|
||||
/** Owned funds (paid-up share capital + reserves) as on that date, the base of the 2% limit on co-operative shares. */
|
||||
private final BigDecimal ownedFunds;
|
||||
/** The same figure worked out from the deposit GLs, as a suggestion. */
|
||||
private final BigDecimal glTotalDeposits;
|
||||
private final BigDecimal grossLimit;
|
||||
private final BigDecimal fdOutstanding;
|
||||
private final BigDecimal bankBalances;
|
||||
private final BigDecimal grossAfter;
|
||||
private final BigDecimal bankLimit;
|
||||
private final String partyName;
|
||||
private final BigDecimal bankAfter;
|
||||
private final BigDecimal newAmount;
|
||||
private final boolean withinLimits;
|
||||
private final String message;
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** Interest lines of an FD: 31 Mar / 30 Sep provisions, payouts of a half-yearly / yearly FD, and maturity. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentFdScheduleData {
|
||||
|
||||
private final InvestmentFdData fd;
|
||||
/** Provisioned interest not yet received (interest receivable of this FD). */
|
||||
private final BigDecimal provisioned;
|
||||
private final List<Row> rows;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Row {
|
||||
|
||||
/** PROVISION, PAYOUT or MATURITY (shown for information; closed on the Maturity screen). */
|
||||
private final String lineType;
|
||||
private final String intDate;
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
private final long days;
|
||||
private final BigDecimal grossInterest;
|
||||
/** Payout: the part already provisioned, cleared from interest receivable. */
|
||||
private final BigDecimal alreadyBooked;
|
||||
private final BigDecimal tds;
|
||||
/** Provision: interest booked. Payout: interest income of the payout (gross less already booked). */
|
||||
private final BigDecimal amount;
|
||||
private final boolean posted;
|
||||
private final String voucherNo;
|
||||
private final String glTransactionId;
|
||||
/** The next line to post, and its date has come. */
|
||||
private final boolean postable;
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** The next unposted interest line of a holding that is due, or due within the reminder window — a task to post. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentInterestDueData {
|
||||
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final String lineType;
|
||||
private final String intDate;
|
||||
private final BigDecimal grossInterest;
|
||||
private final BigDecimal amount;
|
||||
/** The date has come: it can be posted now. */
|
||||
private final boolean postable;
|
||||
/** The date has passed without posting. */
|
||||
private final boolean overdue;
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** An interest line (coupon or closing provision) already posted to GL with its voucher — for the transaction log. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentInterestPostingData {
|
||||
|
||||
private final Long id;
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String bondType;
|
||||
private final String scriptDesc;
|
||||
private final String lineType;
|
||||
private final String intDate;
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
private final BigDecimal grossInterest;
|
||||
private final BigDecimal alreadyBooked;
|
||||
private final BigDecimal amount;
|
||||
/** Debit side: the bank for a coupon, interest receivable for a provision. */
|
||||
private final String drGlCode;
|
||||
private final String drGlName;
|
||||
private final String incomeGlCode;
|
||||
private final String incomeGlName;
|
||||
private final String glTransactionId;
|
||||
private final String voucherNo;
|
||||
private final String postedOnDate;
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Total Interest Received & Receivable: per Govt Security / bond deal and FD, the interest received in a period and
|
||||
* the interest earned up to its last day that has not come in yet.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentInterestReportData {
|
||||
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
private final BigDecimal totalReceived;
|
||||
private final BigDecimal totalReceivable;
|
||||
private final BigDecimal totalProvisioned;
|
||||
private final BigDecimal total;
|
||||
private final List<Row> rows;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Row {
|
||||
|
||||
/** SECURITY or FD. */
|
||||
private final String instrument;
|
||||
private final Long refId;
|
||||
private final String dealNo;
|
||||
/** Script, or bank and certificate of an FD. */
|
||||
private final String particulars;
|
||||
private final String status;
|
||||
/** Face value of the deal as bought, or the FD principal. */
|
||||
private final BigDecimal principal;
|
||||
private final BigDecimal rate;
|
||||
/** Interest that came in during the period (coupons, FD payouts, interest paid at closure), gross of TDS. */
|
||||
private final BigDecimal received;
|
||||
/** Interest earned up to the To date and not yet received. */
|
||||
private final BigDecimal receivable;
|
||||
/** Part of the receivable already booked by 31 Mar / 30 Sep provisions (Interest Receivable 100603). */
|
||||
private final BigDecimal provisioned;
|
||||
private final BigDecimal total;
|
||||
}
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Interest schedule of one holding: every half-yearly coupon and every 31 March / 30 September closing provision from
|
||||
* purchase to maturity. Calculated, not yet posted.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentInterestScheduleData {
|
||||
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final BigDecimal faceValue;
|
||||
private final BigDecimal unitValue;
|
||||
private final BigDecimal balanceUnits;
|
||||
private final BigDecimal purchaseRate;
|
||||
private final BigDecimal bookValue;
|
||||
private final BigDecimal intRate;
|
||||
private final String dealDate;
|
||||
private final String lastIntDate;
|
||||
private final String maturityDate;
|
||||
/**
|
||||
* Broken-period interest paid to the seller at purchase — booked as an expense (GL 500606), not in this schedule.
|
||||
*/
|
||||
private final BigDecimal purchasedAccruedInterest;
|
||||
private final BigDecimal couponPerHalfYear;
|
||||
private final String asOnDate;
|
||||
private final String interestGlCode;
|
||||
private final String interestGlName;
|
||||
/** Bank the coupon is received into by default: the purchase's paying bank. */
|
||||
private final Long bankGlAccountId;
|
||||
private final String bankGlCode;
|
||||
private final String bankGlName;
|
||||
private final String status;
|
||||
private final List<Row> rows;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Row {
|
||||
|
||||
/** COUPON (cash received) or PROVISION (closing accrual). */
|
||||
private final String type;
|
||||
private final String intDate;
|
||||
private final String fromDate;
|
||||
private final String toDate;
|
||||
private final int days;
|
||||
/** Coupon received, or interest earned since the last coupon up to the closing. */
|
||||
private final BigDecimal grossInterest;
|
||||
/** Interest Receivable balance before this line (purchased accrued interest / earlier provision). */
|
||||
private final BigDecimal alreadyBooked;
|
||||
/** Provision to book now, or the part of the coupon that is new income. */
|
||||
private final BigDecimal amount;
|
||||
/** True when the date is on or before the as-on date. */
|
||||
private final boolean due;
|
||||
/** Posted to GL: its JV voucher and GL transaction. */
|
||||
private final boolean posted;
|
||||
private final String voucherNo;
|
||||
private final String glTransactionId;
|
||||
private final String postedOnDate;
|
||||
/** The next line to post and its date has come. */
|
||||
private final boolean postable;
|
||||
/** Unposted and its date is within the reminder window (or past). */
|
||||
private final boolean dueSoon;
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/** Where a new non-SLR purchase stands against the RBI limits on non-SLR and unlisted non-SLR investments. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentNonSlrLimitData {
|
||||
|
||||
/** The 31 March the limits are based on. */
|
||||
private final String asOnDate;
|
||||
/** Total deposits as on that date, as entered for the FD limits; empty until someone enters it. */
|
||||
private final BigDecimal totalDeposits;
|
||||
/** 10% of total deposits. */
|
||||
private final BigDecimal nonSlrLimit;
|
||||
/** Book value of non-SLR investments held or pending settlement, before this deal. */
|
||||
private final BigDecimal nonSlrOutstanding;
|
||||
private final BigDecimal nonSlrAfter;
|
||||
/** Book value of non-SLR investments held on that 31 March. */
|
||||
private final BigDecimal nonSlrBase;
|
||||
/** 10% of the non-SLR investments held on that 31 March. */
|
||||
private final BigDecimal unlistedLimit;
|
||||
private final BigDecimal unlistedOutstanding;
|
||||
private final BigDecimal unlistedAfter;
|
||||
private final BigDecimal newAmount;
|
||||
/** Whether this deal's security is listed or proposed to be listed; unlisted ones also count against the unlisted limit. */
|
||||
private final boolean listed;
|
||||
private final boolean withinLimits;
|
||||
private final String message;
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentPartyData {
|
||||
|
||||
private final Long id;
|
||||
private final String partyCode;
|
||||
private final String bondType;
|
||||
private final String partyName;
|
||||
private final String address1;
|
||||
private final String address2;
|
||||
private final String address3;
|
||||
private final String city;
|
||||
private final String pincode;
|
||||
private final String phone;
|
||||
private final String fax;
|
||||
private final String bankType;
|
||||
private final Long glAccountId;
|
||||
private final String glCode;
|
||||
private final String glName;
|
||||
private final Long interestGlAccountId;
|
||||
private final String interestGlCode;
|
||||
private final String interestGlName;
|
||||
private final boolean tdsApplicable;
|
||||
private final BigDecimal tdsRate;
|
||||
private final String rbiCode;
|
||||
private final BigDecimal limit;
|
||||
private final String leiNo;
|
||||
private final String cpBankName;
|
||||
private final String cpBranchName;
|
||||
private final String cpAccountNo;
|
||||
private final String cpIfsc;
|
||||
private final String institutionType;
|
||||
private final BigDecimal subscribedCapital;
|
||||
private final String lastBalanceSheetDate;
|
||||
private final boolean inLiquidation;
|
||||
private final boolean active;
|
||||
|
||||
public static InvestmentPartyData from(final InvestmentParty p) {
|
||||
return InvestmentPartyData.builder().id(p.getId()).partyCode(p.getPartyCode()).bondType(p.getBondType())
|
||||
.partyName(p.getPartyName()).address1(p.getAddress1()).address2(p.getAddress2()).address3(p.getAddress3())
|
||||
.city(p.getCity()).pincode(p.getPincode()).phone(p.getPhone()).fax(p.getFax()).bankType(p.getBankType())
|
||||
.glAccountId(p.getGlAccount().getId()).glCode(p.getGlAccount().getGlCode()).glName(p.getGlAccount().getName())
|
||||
.interestGlAccountId(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getId())
|
||||
.interestGlCode(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getGlCode())
|
||||
.interestGlName(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getName())
|
||||
.tdsApplicable(p.isTdsApplicable()).tdsRate(p.getTdsRate()).rbiCode(p.getRbiCode()).limit(p.getExposureLimit())
|
||||
.leiNo(p.getLeiNo()).cpBankName(p.getCpBankName())
|
||||
.cpBranchName(p.getCpBranchName()).cpAccountNo(p.getCpAccountNo()).cpIfsc(p.getCpIfsc())
|
||||
.institutionType(p.getInstitutionType()).subscribedCapital(p.getSubscribedCapital())
|
||||
.lastBalanceSheetDate(p.getLastBalanceSheetDate() == null ? null
|
||||
: p.getLastBalanceSheetDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")))
|
||||
.inLiquidation(p.isInLiquidation()).active(p.isActive()).build();
|
||||
}
|
||||
}
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentPurchaseData {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final Long id;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String bondType;
|
||||
private final Long scriptId;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final String isin;
|
||||
private final Long partyId;
|
||||
private final String partyCode;
|
||||
private final String partyName;
|
||||
private final String dealDate;
|
||||
private final String dealTime;
|
||||
private final String settlementType;
|
||||
private final String settlementDate;
|
||||
private final BigDecimal faceValue;
|
||||
private final BigDecimal purchaseRate;
|
||||
private final BigDecimal units;
|
||||
private final BigDecimal balanceUnits;
|
||||
private final BigDecimal brokerage;
|
||||
private final BigDecimal stampDuty;
|
||||
private final BigDecimal otherCharges;
|
||||
private final BigDecimal bookValue;
|
||||
private final BigDecimal premiumDiscount;
|
||||
private final BigDecimal intRate;
|
||||
private final String issueDate;
|
||||
private final String maturityDate;
|
||||
private final String lastIntDate;
|
||||
private final Integer accruedDays;
|
||||
private final BigDecimal accruedInterest;
|
||||
private final BigDecimal settlementAmount;
|
||||
private final BigDecimal ytm;
|
||||
private final String category;
|
||||
private final String subType;
|
||||
private final Long investmentGlAccountId;
|
||||
private final String investmentGlCode;
|
||||
private final String investmentGlName;
|
||||
private final Long crGlAccountId;
|
||||
private final String crGlCode;
|
||||
private final String crGlName;
|
||||
private final String otherDetails;
|
||||
private final String status;
|
||||
private final String glTransactionId;
|
||||
private final String settledOnDate;
|
||||
|
||||
public static InvestmentPurchaseData from(final InvestmentPurchase p) {
|
||||
return InvestmentPurchaseData.builder().id(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()).bondType(p.getBondType())
|
||||
.scriptId(p.getScript().getId()).scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc())
|
||||
.isin(p.getScript().getIsin()).partyId(p.getParty().getId()).partyCode(p.getParty().getPartyCode())
|
||||
.partyName(p.getParty().getPartyName()).dealDate(format(p.getDealDate())).dealTime(p.getDealTime())
|
||||
.settlementType(p.getSettlementType()).settlementDate(format(p.getSettlementDate())).faceValue(p.getFaceValue())
|
||||
.purchaseRate(p.getPurchaseRate()).units(p.getUnits()).balanceUnits(p.getBalanceUnits()).brokerage(p.getBrokerage())
|
||||
.stampDuty(p.getStampDuty()).otherCharges(p.getOtherCharges()).bookValue(p.getBookValue())
|
||||
.premiumDiscount(p.getPremiumDiscount()).intRate(p.getIntRate()).issueDate(format(p.getIssueDate()))
|
||||
.maturityDate(format(p.getMaturityDate())).lastIntDate(format(p.getLastIntDate())).accruedDays(p.getAccruedDays())
|
||||
.accruedInterest(p.getAccruedInterest()).settlementAmount(p.getSettlementAmount()).ytm(p.getYtm()).category(p.getCategory())
|
||||
.subType(p.getSubType()).investmentGlAccountId(p.getInvestmentGlAccount().getId())
|
||||
.investmentGlCode(p.getInvestmentGlAccount().getGlCode()).investmentGlName(p.getInvestmentGlAccount().getName())
|
||||
.crGlAccountId(p.getCrGlAccount().getId()).crGlCode(p.getCrGlAccount().getGlCode()).crGlName(p.getCrGlAccount().getName())
|
||||
.otherDetails(p.getOtherDetails()).status(p.getStatus()).glTransactionId(p.getGlTransactionId())
|
||||
.settledOnDate(format(p.getSettledOnDate())).build();
|
||||
}
|
||||
|
||||
private static String format(final LocalDate date) {
|
||||
return date == null ? null : date.format(DATE_FORMAT);
|
||||
}
|
||||
}
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
|
||||
|
||||
/** A sale or redemption of a holding — also the shape of a quote before it is saved. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentSaleData {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final Long id;
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String bondType;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final String saleType;
|
||||
private final Long partyId;
|
||||
private final String partyName;
|
||||
private final String dealDate;
|
||||
private final String dealTime;
|
||||
private final String settlementType;
|
||||
private final String settlementDate;
|
||||
private final BigDecimal units;
|
||||
private final BigDecimal faceValue;
|
||||
private final BigDecimal saleRate;
|
||||
private final BigDecimal consideration;
|
||||
private final Integer accruedDays;
|
||||
private final BigDecimal accruedInterest;
|
||||
private final BigDecimal receivableCleared;
|
||||
/** Interest from the buyer not yet booked: accrued interest less the receivable cleared. */
|
||||
private final BigDecimal interestIncome;
|
||||
private final BigDecimal brokerage;
|
||||
private final BigDecimal bookValueSold;
|
||||
private final BigDecimal premiumCleared;
|
||||
private final BigDecimal profitLoss;
|
||||
private final BigDecimal netAmount;
|
||||
private final Long bankGlAccountId;
|
||||
private final String bankGlCode;
|
||||
private final String bankGlName;
|
||||
private final String investmentGlCode;
|
||||
/** GL the premium share is credited to: 100707, or the investment GL for deals settled before it was used. */
|
||||
private final String premiumGlCode;
|
||||
private final String sanctionAuthority;
|
||||
private final String remarks;
|
||||
private final String status;
|
||||
private final String glTransactionId;
|
||||
private final String voucherNo;
|
||||
private final String settledOnDate;
|
||||
|
||||
public static InvestmentSaleData from(final InvestmentSale s) {
|
||||
return builder().id(s.getId()).purchaseId(s.getPurchase().getId()).dealNo(s.getPurchase().getDealNo())
|
||||
.certNo(s.getPurchase().getCertNo()).bondType(s.getPurchase().getBondType())
|
||||
.scriptCode(s.getPurchase().getScript().getScriptCode()).scriptDesc(s.getPurchase().getScript().getScriptDesc())
|
||||
.saleType(s.getSaleType()).partyId(s.getParty() == null ? null : s.getParty().getId())
|
||||
.partyName(s.getParty() == null ? null : s.getParty().getPartyName()).dealDate(format(s.getDealDate()))
|
||||
.dealTime(s.getDealTime()).settlementType(s.getSettlementType()).settlementDate(format(s.getSettlementDate()))
|
||||
.units(s.getUnits()).faceValue(s.getFaceValue()).saleRate(s.getSaleRate()).consideration(s.getConsideration())
|
||||
.accruedDays(s.getAccruedDays()).accruedInterest(s.getAccruedInterest()).receivableCleared(s.getReceivableCleared())
|
||||
.interestIncome(zero(s.getAccruedInterest()).subtract(zero(s.getReceivableCleared()))).brokerage(s.getBrokerage())
|
||||
.bookValueSold(s.getBookValueSold()).premiumCleared(s.getPremiumCleared()).profitLoss(s.getProfitLoss()).netAmount(s.getNetAmount())
|
||||
.bankGlAccountId(s.getBankGlAccount().getId()).bankGlCode(s.getBankGlAccount().getGlCode())
|
||||
.bankGlName(s.getBankGlAccount().getName()).investmentGlCode(s.getPurchase().getInvestmentGlAccount().getGlCode())
|
||||
.premiumGlCode(s.getPurchase().getPremiumGlAccount() != null ? s.getPurchase().getPremiumGlAccount().getGlCode()
|
||||
: s.getPurchase().getInvestmentGlAccount().getGlCode())
|
||||
.sanctionAuthority(s.getSanctionAuthority()).remarks(s.getRemarks()).status(s.getStatus())
|
||||
.glTransactionId(s.getGlTransactionId()).voucherNo(s.getVoucherNo()).settledOnDate(format(s.getSettledOnDate())).build();
|
||||
}
|
||||
|
||||
private static BigDecimal zero(final BigDecimal v) {
|
||||
return v == null ? BigDecimal.ZERO : v;
|
||||
}
|
||||
|
||||
private static String format(final LocalDate date) {
|
||||
return date == null ? null : date.format(DATE_FORMAT);
|
||||
}
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentScriptData {
|
||||
|
||||
/** Dates go out as DD-MM-YYYY, the format the Investment screens use throughout. */
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final Long id;
|
||||
private final String scriptCode;
|
||||
private final String bondType;
|
||||
private final String slrType;
|
||||
private final String scriptDesc;
|
||||
private final Long glAccountId;
|
||||
private final String glCode;
|
||||
private final String glName;
|
||||
private final String isin;
|
||||
private final BigDecimal faceValuePerUnit;
|
||||
private final BigDecimal intRate;
|
||||
private final String issueDate;
|
||||
private final String maturityDate;
|
||||
private final String companyName;
|
||||
private final String address1;
|
||||
private final String address2;
|
||||
private final String address3;
|
||||
private final String city;
|
||||
private final String pincode;
|
||||
private final String phone;
|
||||
private final String fax;
|
||||
private final String corpCode;
|
||||
private final String rating;
|
||||
private final String ratingAgency;
|
||||
private final boolean listed;
|
||||
private final boolean active;
|
||||
|
||||
public static InvestmentScriptData from(final InvestmentScript s) {
|
||||
return InvestmentScriptData.builder().id(s.getId()).scriptCode(s.getScriptCode()).bondType(s.getBondType())
|
||||
.slrType(s.getSlrType()).scriptDesc(s.getScriptDesc()).glAccountId(s.getGlAccount().getId())
|
||||
.glCode(s.getGlAccount().getGlCode()).glName(s.getGlAccount().getName()).isin(s.getIsin())
|
||||
.faceValuePerUnit(s.getFaceValuePerUnit()).intRate(s.getIntRate()).issueDate(format(s.getIssueDate()))
|
||||
.maturityDate(format(s.getMaturityDate())).companyName(s.getCompanyName()).address1(s.getAddress1())
|
||||
.address2(s.getAddress2()).address3(s.getAddress3()).city(s.getCity()).pincode(s.getPincode()).phone(s.getPhone())
|
||||
.fax(s.getFax()).corpCode(s.getCorpCode()).rating(s.getRating()).ratingAgency(s.getRatingAgency()).listed(s.isListed())
|
||||
.active(s.isActive()).build();
|
||||
}
|
||||
|
||||
private static String format(final LocalDate date) {
|
||||
return date == null ? null : date.format(DATE_FORMAT);
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransfer;
|
||||
|
||||
/** A GOI Category Transfer of a holding, for the transaction log. */
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentSecurityTransferData {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
private final Long id;
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String bondType;
|
||||
private final String fromScriptCode;
|
||||
private final String fromScriptDesc;
|
||||
private final String toScriptCode;
|
||||
private final String toScriptDesc;
|
||||
private final String fromGlCode;
|
||||
private final String toGlCode;
|
||||
private final String toGlName;
|
||||
private final String transferDate;
|
||||
private final BigDecimal units;
|
||||
private final BigDecimal bookValue;
|
||||
private final String remarks;
|
||||
private final String glTransactionId;
|
||||
private final String voucherNo;
|
||||
|
||||
public static InvestmentSecurityTransferData from(final InvestmentSecurityTransfer t) {
|
||||
return builder().id(t.getId()).purchaseId(t.getPurchase().getId()).dealNo(t.getPurchase().getDealNo())
|
||||
.certNo(t.getPurchase().getCertNo()).bondType(t.getPurchase().getBondType()).fromScriptCode(t.getFromScript().getScriptCode())
|
||||
.fromScriptDesc(t.getFromScript().getScriptDesc()).toScriptCode(t.getToScript().getScriptCode())
|
||||
.toScriptDesc(t.getToScript().getScriptDesc()).fromGlCode(t.getFromGlAccount().getGlCode())
|
||||
.toGlCode(t.getToGlAccount().getGlCode()).toGlName(t.getToGlAccount().getName())
|
||||
.transferDate(t.getTransferDate().format(DATE_FORMAT)).units(t.getUnits()).bookValue(t.getBookValue())
|
||||
.remarks(t.getRemarks()).glTransactionId(t.getGlTransactionId()).voucherNo(t.getVoucherNo()).build();
|
||||
}
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Where a share purchase stands against the RBI limits: shares of co-operative societies within 2% of owned funds, and
|
||||
* within 5% of one society's subscribed capital. Membership shares (DCCB, State Co-op Bank, NPCI / CCIL / SWIFT, Umbrella
|
||||
* Organisation, IDPIC) may exceed them.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentShareLimitData {
|
||||
|
||||
/** The 31 March the owned funds are taken from. */
|
||||
private final String asOnDate;
|
||||
private final String institutionType;
|
||||
/** True for membership shares, which the limits do not bind. */
|
||||
private final boolean exempt;
|
||||
private final BigDecimal ownedFunds;
|
||||
private final BigDecimal coopLimit;
|
||||
private final BigDecimal coopOutstanding;
|
||||
private final BigDecimal coopAfter;
|
||||
private final String partyName;
|
||||
private final BigDecimal subscribedCapital;
|
||||
private final BigDecimal institutionLimit;
|
||||
private final BigDecimal institutionOutstanding;
|
||||
private final BigDecimal institutionAfter;
|
||||
private final BigDecimal newAmount;
|
||||
private final boolean withinLimits;
|
||||
private final String message;
|
||||
}
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* 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.investment.data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* A market valuation (MTM) of AFS or HFT holdings: a preview before posting (no id) or a posted run. Each security is
|
||||
* valued at the Daily Script Master rate; the IDR needed per class is its net loss, a net gain is ignored.
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class InvestmentValuationData {
|
||||
|
||||
private final Long id;
|
||||
private final String valuationDate;
|
||||
private final String category;
|
||||
private final BigDecimal requiredTotal;
|
||||
/** IDR held from the category's previous run. */
|
||||
private final BigDecimal previousTotal;
|
||||
/** Posted (or to be posted): more provision when positive, a write-back when negative. */
|
||||
private final BigDecimal delta;
|
||||
private final String provisionGlCode;
|
||||
private final String reserveGlCode;
|
||||
private final String glTransactionId;
|
||||
private final String voucherNo;
|
||||
/** Date of the category's last run, if any; a new run must be dated after it. */
|
||||
private final String lastRunDate;
|
||||
/** Scripts held with no rate on or before the valuation date: the run is refused until they are entered. */
|
||||
private final List<String> missingRates;
|
||||
/** A preview can be posted: no rate missing, dated after the last run, and something to value or to write back. */
|
||||
private final boolean postable;
|
||||
private final List<ClassTotal> classes;
|
||||
private final List<Line> lines;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class ClassTotal {
|
||||
|
||||
/** GOVT_SECURITY, CORPORATE_BOND or MUTUAL_FUND. */
|
||||
private final String assetClass;
|
||||
private final BigDecimal bookValue;
|
||||
private final BigDecimal marketValue;
|
||||
private final BigDecimal netDifference;
|
||||
private final BigDecimal required;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Line {
|
||||
|
||||
private final Long purchaseId;
|
||||
private final String dealNo;
|
||||
private final String certNo;
|
||||
private final String bondType;
|
||||
private final String scriptCode;
|
||||
private final String scriptDesc;
|
||||
private final String assetClass;
|
||||
private final BigDecimal units;
|
||||
private final BigDecimal bookValue;
|
||||
private final BigDecimal rate;
|
||||
private final String rateDate;
|
||||
private final BigDecimal marketValue;
|
||||
private final BigDecimal difference;
|
||||
/** How a share was valued; empty for a holding valued at its market rate. */
|
||||
private final String valuationBasis;
|
||||
private final boolean npi;
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* 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.investment.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/**
|
||||
* Daily Script Master: the market rate of a script for one day, as the bank's existing CBS keeps it. Purchase Entry
|
||||
* takes its rate from here (Jitendra Sir). Close rate is mandatory; open, high and low are optional.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_investment_daily_rate")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class InvestmentDailyRate extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "script_id", nullable = false)
|
||||
private InvestmentScript script;
|
||||
|
||||
@Column(name = "rate_date", nullable = false)
|
||||
private LocalDate rateDate;
|
||||
|
||||
@Column(name = "open_rate", scale = 6, precision = 19)
|
||||
private BigDecimal openRate;
|
||||
|
||||
@Column(name = "high_rate", scale = 6, precision = 19)
|
||||
private BigDecimal highRate;
|
||||
|
||||
@Column(name = "low_rate", scale = 6, precision = 19)
|
||||
private BigDecimal lowRate;
|
||||
|
||||
@Column(name = "close_rate", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal closeRate;
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 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.investment.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface InvestmentDailyRateRepository extends JpaRepository<InvestmentDailyRate, Long> {
|
||||
|
||||
boolean existsByScriptIdAndRateDate(Long scriptId, LocalDate rateDate);
|
||||
|
||||
/** The newest rate of a script on or before a date. */
|
||||
Optional<InvestmentDailyRate> findFirstByScriptIdAndRateDateLessThanEqualOrderByRateDateDesc(Long scriptId, LocalDate onDate);
|
||||
|
||||
List<InvestmentDailyRate> findByScriptIdOrderByRateDateDesc(Long scriptId);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user