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.
|
||||
+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;
|
||||
|
||||
@@ -28,7 +28,6 @@ import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
@@ -62,15 +61,6 @@ import org.apache.fineract.portfolio.client.domain.Client;
|
||||
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
|
||||
public class Agent extends AbstractAuditableCustom {
|
||||
|
||||
/**
|
||||
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
|
||||
* two collections at once, or a collection during a posting run - cannot both win; the second is
|
||||
* refused and retried instead of quietly overwriting the first one's balance.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
-10
@@ -69,16 +69,6 @@ public class CommissionSlab extends AbstractAuditableCustom {
|
||||
* Corrects this slab, reporting only what actually changed so the audit trail records the
|
||||
* correction rather than a wholesale rewrite.
|
||||
*/
|
||||
/** Moves where this slab starts - used when the slab before it is corrected, so the two still meet. */
|
||||
public void startAt(final BigDecimal from) {
|
||||
this.slabFrom = from;
|
||||
}
|
||||
|
||||
/** Moves where this slab ends - used when the slab after it is corrected, so the two still meet. */
|
||||
public void endAt(final BigDecimal to) {
|
||||
this.slabTo = to;
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
|
||||
|
||||
|
||||
+1
-16
@@ -41,6 +41,7 @@ public final class DdsAccountTypeData implements Serializable {
|
||||
private final String accountTypeDescription;
|
||||
private final Boolean autoInterestPost;
|
||||
private final String autoInterestPostWhen;
|
||||
private final Integer autoInterestPostDay;
|
||||
/** Whether an account on this scheme closes itself on its maturity date. */
|
||||
private final Boolean maturityAutoClose;
|
||||
/**
|
||||
@@ -55,22 +56,6 @@ public final class DdsAccountTypeData implements Serializable {
|
||||
/** The scheme's annual interest rate, which an account opened on it takes. */
|
||||
private final BigDecimal interestRate;
|
||||
private final Boolean active;
|
||||
/** The product's own ledger accounts (see DdsAccountType), each with its code and name to show it by. */
|
||||
private final Long depositLiabilityGlId;
|
||||
private final String depositLiabilityGlCode;
|
||||
private final String depositLiabilityGlName;
|
||||
private final Long interestExpenseGlId;
|
||||
private final String interestExpenseGlCode;
|
||||
private final String interestExpenseGlName;
|
||||
private final Long commissionExpenseGlId;
|
||||
private final String commissionExpenseGlCode;
|
||||
private final String commissionExpenseGlName;
|
||||
private final Long prematureIncomeGlId;
|
||||
private final String prematureIncomeGlCode;
|
||||
private final String prematureIncomeGlName;
|
||||
private final Long tdsPayableGlId;
|
||||
private final String tdsPayableGlCode;
|
||||
private final String tdsPayableGlName;
|
||||
private final LocalDateTime createdDate;
|
||||
private final LocalDateTime lastModifiedDate;
|
||||
}
|
||||
|
||||
+18
-60
@@ -60,6 +60,10 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
@Column(name = "auto_interest_post_when", length = 20)
|
||||
private String autoInterestPostWhen;
|
||||
|
||||
/** Day of the month interest posts on. Only meaningful when {@code autoInterestPost} is set. */
|
||||
@Column(name = "auto_interest_post_day")
|
||||
private Integer autoInterestPostDay;
|
||||
|
||||
/**
|
||||
* Whether an account on this scheme closes itself the day it matures, instead of waiting for a
|
||||
* clerk to pay it out by hand.
|
||||
@@ -115,35 +119,6 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private boolean active;
|
||||
|
||||
/**
|
||||
* The ledger accounts this product posts through, chosen on its Accounting tab the way a savings
|
||||
* product chooses its own. DDS posts through these alone - there is no bank-wide account to fall
|
||||
* back on - so a product missing one stops its postings with a message naming what to set.
|
||||
*
|
||||
* What the bank owes this product's customers - DDS Customer Deposits, or the product's own.
|
||||
*/
|
||||
@Column(name = "deposit_liability_gl_id")
|
||||
private Long depositLiabilityGlId;
|
||||
|
||||
/** What interest paid to this product's customers costs the bank. */
|
||||
@Column(name = "interest_expense_gl_id")
|
||||
private Long interestExpenseGlId;
|
||||
|
||||
/** What the agents' commission on collecting for this product costs the bank. */
|
||||
@Column(name = "commission_expense_gl_id")
|
||||
private Long commissionExpenseGlId;
|
||||
|
||||
/**
|
||||
* What an early closure earns the bank: the interest a customer gives back, and the penalty taken off
|
||||
* the agent's commission.
|
||||
*/
|
||||
@Column(name = "premature_income_gl_id")
|
||||
private Long prematureIncomeGlId;
|
||||
|
||||
/** Where TDS withheld from the agents' commission on this product (Section 194H) is owed until paid over. */
|
||||
@Column(name = "tds_payable_gl_id")
|
||||
private Long tdsPayableGlId;
|
||||
|
||||
protected DdsAccountType() {
|
||||
//
|
||||
}
|
||||
@@ -155,16 +130,14 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
this.autoInterestPost = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost");
|
||||
this.autoInterestPostWhen = StringUtils
|
||||
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("autoInterestPostWhen"), null);
|
||||
this.autoInterestPostDay = this.autoInterestPost
|
||||
? command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay")
|
||||
: null;
|
||||
this.maturityAutoClose = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
|
||||
this.prematureLockinMonths = valueOr(command, "prematureLockinMonths", DEFAULT_LOCKIN_MONTHS);
|
||||
this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION);
|
||||
this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY);
|
||||
this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate");
|
||||
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
|
||||
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
|
||||
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
|
||||
this.prematureIncomeGlId = command.longValueOfParameterNamed("prematureIncomeGlId");
|
||||
this.tdsPayableGlId = command.longValueOfParameterNamed("tdsPayableGlId");
|
||||
// A newly created account type is available for use straight away.
|
||||
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
}
|
||||
@@ -239,12 +212,23 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
this.autoInterestPostWhen = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
if (command.isChangeInIntegerParameterNamed("autoInterestPostDay", this.autoInterestPostDay)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay");
|
||||
actualChanges.put("autoInterestPostDay", newValue);
|
||||
this.autoInterestPostDay = newValue;
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("maturityAutoClose", this.maturityAutoClose)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
|
||||
actualChanges.put("maturityAutoClose", newValue);
|
||||
this.maturityAutoClose = newValue;
|
||||
}
|
||||
|
||||
// A posting day only means anything while auto-posting is on.
|
||||
if (!this.autoInterestPost) {
|
||||
this.autoInterestPostDay = null;
|
||||
}
|
||||
|
||||
if (command.isChangeInIntegerParameterNamed("prematureLockinMonths", this.prematureLockinMonths)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("prematureLockinMonths");
|
||||
actualChanges.put("prematureLockinMonths", newValue);
|
||||
@@ -272,32 +256,6 @@ public class DdsAccountType extends AbstractAuditableCustom {
|
||||
this.interestRate = newValue;
|
||||
}
|
||||
|
||||
// The product's ledger accounts. All three are required; the deserializer refuses a null.
|
||||
if (command.parameterExists("depositLiabilityGlId")
|
||||
&& command.isChangeInLongParameterNamed("depositLiabilityGlId", this.depositLiabilityGlId)) {
|
||||
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
|
||||
actualChanges.put("depositLiabilityGlId", this.depositLiabilityGlId);
|
||||
}
|
||||
if (command.parameterExists("interestExpenseGlId")
|
||||
&& command.isChangeInLongParameterNamed("interestExpenseGlId", this.interestExpenseGlId)) {
|
||||
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
|
||||
actualChanges.put("interestExpenseGlId", this.interestExpenseGlId);
|
||||
}
|
||||
if (command.parameterExists("commissionExpenseGlId")
|
||||
&& command.isChangeInLongParameterNamed("commissionExpenseGlId", this.commissionExpenseGlId)) {
|
||||
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
|
||||
actualChanges.put("commissionExpenseGlId", this.commissionExpenseGlId);
|
||||
}
|
||||
if (command.parameterExists("prematureIncomeGlId")
|
||||
&& command.isChangeInLongParameterNamed("prematureIncomeGlId", this.prematureIncomeGlId)) {
|
||||
this.prematureIncomeGlId = command.longValueOfParameterNamed("prematureIncomeGlId");
|
||||
actualChanges.put("prematureIncomeGlId", this.prematureIncomeGlId);
|
||||
}
|
||||
if (command.parameterExists("tdsPayableGlId") && command.isChangeInLongParameterNamed("tdsPayableGlId", this.tdsPayableGlId)) {
|
||||
this.tdsPayableGlId = command.longValueOfParameterNamed("tdsPayableGlId");
|
||||
actualChanges.put("tdsPayableGlId", this.tdsPayableGlId);
|
||||
}
|
||||
|
||||
if (command.isChangeInBooleanParameterNamed("active", this.active)) {
|
||||
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
|
||||
actualChanges.put("active", newValue);
|
||||
|
||||
-23
@@ -25,7 +25,6 @@ import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
@@ -46,15 +45,6 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
@Table(name = "m_dds_batch_run")
|
||||
public class BatchRun extends AbstractAuditableCustom {
|
||||
|
||||
/**
|
||||
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
|
||||
* two collections at once, or a collection during a posting run - cannot both win; the second is
|
||||
* refused and retried instead of quietly overwriting the first one's balance.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
@Column(name = "job_type", length = 40, nullable = false)
|
||||
private String jobType;
|
||||
|
||||
@@ -97,14 +87,6 @@ public class BatchRun extends AbstractAuditableCustom {
|
||||
@Column(name = "rows_posted", nullable = false)
|
||||
private Integer rowsPosted = 0;
|
||||
|
||||
/**
|
||||
* What the run actually moved, beside totalAmount - what it was generated to move. They differ when
|
||||
* rows were passed over at posting (the money had already been paid by another run), and the run
|
||||
* says so rather than reading as though the whole total went through.
|
||||
*/
|
||||
@Column(name = "posted_amount", scale = 6, precision = 19)
|
||||
private BigDecimal postedAmount;
|
||||
|
||||
/** Why a run stopped, when it did. A background run has nowhere else to say so. */
|
||||
@Column(name = "failure_reason", length = 500)
|
||||
private String failureReason;
|
||||
@@ -173,11 +155,6 @@ public class BatchRun extends AbstractAuditableCustom {
|
||||
this.rowsPosted = posted;
|
||||
}
|
||||
|
||||
public void recordPosted(final int posted, final BigDecimal amount) {
|
||||
this.rowsPosted = posted;
|
||||
this.postedAmount = amount;
|
||||
}
|
||||
|
||||
public void markPosted(final LocalDate postedOnDate) {
|
||||
this.status = STATUS_POSTED;
|
||||
this.postedOnDate = postedOnDate;
|
||||
|
||||
-10
@@ -23,7 +23,6 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
@@ -50,15 +49,6 @@ import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
@Table(name = "m_dds_collection_entry")
|
||||
public class CollectionEntry extends AbstractAuditableCustom {
|
||||
|
||||
/**
|
||||
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
|
||||
* two collections at once, or a collection during a posting run - cannot both win; the second is
|
||||
* refused and retried instead of quietly overwriting the first one's balance.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
/** Waiting for a clerk to count the cash behind it. */
|
||||
public static final String PENDING = "PENDING";
|
||||
|
||||
|
||||
-6
@@ -178,11 +178,5 @@ public final class PartyData implements Serializable {
|
||||
private final String nomineeSplitMode;
|
||||
private final String status;
|
||||
private final LocalDate closeDate;
|
||||
/** The product by id, the account currency, and who opened (made operative) and closed it - as a savings account shows. */
|
||||
private final Long accountTypeId;
|
||||
private final String currencyCode;
|
||||
private final LocalDate activatedOnDate;
|
||||
private final String activatedByUsername;
|
||||
private final String closedByUsername;
|
||||
private final Collection<PartyNomineeData> nominees;
|
||||
}
|
||||
|
||||
+6
-70
@@ -28,7 +28,6 @@ import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OrderBy;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.persistence.Version;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
@@ -61,15 +60,6 @@ import org.apache.fineract.portfolio.client.domain.Client;
|
||||
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
|
||||
public class Party extends AbstractAuditableCustom {
|
||||
|
||||
/**
|
||||
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
|
||||
* two collections at once, or a collection during a posting run - cannot both win; the second is
|
||||
* refused and retried instead of quietly overwriting the first one's balance.
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */
|
||||
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
|
||||
|
||||
@@ -80,10 +70,6 @@ public class Party extends AbstractAuditableCustom {
|
||||
|
||||
public static final String STATUS_ACTIVE = "ACTIVE";
|
||||
public static final String STATUS_CLOSED = "CLOSED";
|
||||
/** Past its maturity date and not yet paid out - set by the nightly Post DDS Interest job, as FD's MATURED. */
|
||||
public static final String STATUS_MATURED = "MATURED";
|
||||
/** Closed before maturity, the shortfall settled - FD's Premature Closure. */
|
||||
public static final String STATUS_PREMATURE_CLOSED = "PREMATURE_CLOSED";
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
@@ -318,29 +304,6 @@ public class Party extends AbstractAuditableCustom {
|
||||
@Column(name = "close_date")
|
||||
private LocalDate closeDate;
|
||||
|
||||
/**
|
||||
* The DDS product the account is on, by id - subType carries its code for the screens, but a code
|
||||
* can be renamed and an id cannot, so this is what ties the account to its product's terms and
|
||||
* ledger accounts (as m_savings_account.product_id does).
|
||||
*/
|
||||
@Column(name = "account_type_id")
|
||||
private Long accountTypeId;
|
||||
|
||||
/** The account's currency, as a savings account records its own - set from the bank's currency on opening. */
|
||||
@Column(name = "currency_code", length = 3)
|
||||
private String currencyCode;
|
||||
|
||||
/** When the account became operative, and who made it so - the checker, where the opening was approved. */
|
||||
@Column(name = "activatedon_date")
|
||||
private LocalDate activatedOnDate;
|
||||
|
||||
@Column(name = "activatedon_userid")
|
||||
private Long activatedByUserId;
|
||||
|
||||
/** Who closed the account, beside closeDate - when it was closed. */
|
||||
@Column(name = "closedon_userid")
|
||||
private Long closedByUserId;
|
||||
|
||||
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OrderBy("id")
|
||||
private List<PartyNominee> nominees = new ArrayList<>();
|
||||
@@ -529,13 +492,10 @@ public class Party extends AbstractAuditableCustom {
|
||||
this.savingsAccountId = savingsAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbers the account from the DDS account serial (m_dds_account_number_serial), not from its id: an
|
||||
* id is spent by every save that rolls back, and the bank's account numbers must run without gaps.
|
||||
*/
|
||||
public void assignAccountNo(final long serial) {
|
||||
if (this.accountNo == null) {
|
||||
this.accountNo = "DDS" + String.format("%09d", serial);
|
||||
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */
|
||||
public void assignAccountNo() {
|
||||
if (this.accountNo == null && getId() != null) {
|
||||
this.accountNo = "DDS" + String.format("%09d", getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,13 +530,8 @@ public class Party extends AbstractAuditableCustom {
|
||||
private static final String MINOR = "Minor";
|
||||
private static final String ADULT = "Adult";
|
||||
|
||||
/** Closed either way - paid out at maturity, or broken before it. */
|
||||
public boolean isClosed() {
|
||||
return STATUS_CLOSED.equals(this.status) || STATUS_PREMATURE_CLOSED.equals(this.status);
|
||||
}
|
||||
|
||||
public boolean isMatured() {
|
||||
return STATUS_MATURED.equals(this.status);
|
||||
return STATUS_CLOSED.equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,28 +539,9 @@ public class Party extends AbstractAuditableCustom {
|
||||
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
|
||||
* still owed to it.
|
||||
*/
|
||||
public void close(final LocalDate closeDate, final Long closedBy) {
|
||||
public void close(final LocalDate closeDate) {
|
||||
this.status = STATUS_CLOSED;
|
||||
this.closeDate = closeDate;
|
||||
this.closedByUserId = closedBy;
|
||||
}
|
||||
|
||||
/** Closed before its term ran out, as a fixed deposit broken early is - kept apart from a maturity close. */
|
||||
public void closePremature(final LocalDate closeDate, final Long closedBy) {
|
||||
close(closeDate, closedBy);
|
||||
this.status = STATUS_PREMATURE_CLOSED;
|
||||
}
|
||||
|
||||
/** Ties the account to its product by id; called whenever subType is set or changed. */
|
||||
public void linkProduct(final Long productId) {
|
||||
this.accountTypeId = productId;
|
||||
}
|
||||
|
||||
/** Stamps the opening: the account's currency, and the day and user that made it operative. */
|
||||
public void recordActivation(final String currency, final LocalDate on, final Long by) {
|
||||
this.currencyCode = currency;
|
||||
this.activatedOnDate = on;
|
||||
this.activatedByUserId = by;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-12
@@ -22,7 +22,6 @@ import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -52,15 +51,4 @@ public final class PartyTransactionData implements Serializable {
|
||||
private final BigDecimal balanceAfterTransaction;
|
||||
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
|
||||
private final String voucherNo;
|
||||
/** The branch the line is booked at, the business day it was recorded, and the journal it was booked under. */
|
||||
private final Long officeId;
|
||||
private final String officeName;
|
||||
private final LocalDate submittedOnDate;
|
||||
private final String journalTransactionId;
|
||||
/** The line's own unique reference (m_dds_party_transaction.ref_no). */
|
||||
private final String refNo;
|
||||
/** Who keyed the line and when, as a savings transaction shows it. */
|
||||
private final String createdByUsername;
|
||||
private final LocalDateTime createdDate;
|
||||
private final String agentName;
|
||||
}
|
||||
|
||||
-58
@@ -23,14 +23,11 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PostPersist;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
|
||||
@@ -90,36 +87,6 @@ public class PartyTransaction extends AbstractAuditableCustom {
|
||||
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
|
||||
private BigDecimal balanceAfterTransaction;
|
||||
|
||||
/** The branch the line is booked at - the party's, or its agent's for a line with no party - as savings rows carry. */
|
||||
@Column(name = "office_id")
|
||||
private Long officeId;
|
||||
|
||||
/**
|
||||
* The business day the line was recorded, beside transactionDate - the day the money moved. They
|
||||
* differ when a collection is applied from a cashier's receipt days after it was collected.
|
||||
*/
|
||||
@Column(name = "submitted_on_date")
|
||||
private LocalDate submittedOnDate;
|
||||
|
||||
/**
|
||||
* The acc_gl_journal_entry.transaction_id the line was booked under, so a passbook line and its
|
||||
* journal can be found from each other. Null for a line that moves nothing on the books (a transfer
|
||||
* between two customers owed on the same account).
|
||||
*/
|
||||
@Column(name = "journal_transaction_id", length = 100)
|
||||
private String journalTransactionId;
|
||||
|
||||
/**
|
||||
* The line's own reference, unique across DDS, as a savings transaction carries ref_no: what a
|
||||
* statement, a complaint or a reconciliation quotes to name exactly one line.
|
||||
*/
|
||||
@Column(name = "ref_no", length = 40, nullable = false, unique = true)
|
||||
private String refNo;
|
||||
|
||||
/** The voucher the line was printed on - stamped by DdsVoucherService once the operation's voucher is raised. */
|
||||
@Column(name = "voucher_no", length = 30, insertable = false, updatable = false)
|
||||
private String voucherNo;
|
||||
|
||||
protected PartyTransaction() {
|
||||
//
|
||||
}
|
||||
@@ -141,31 +108,6 @@ public class PartyTransaction extends AbstractAuditableCustom {
|
||||
this.tokenNo = tokenNo;
|
||||
this.description = description;
|
||||
this.balanceAfterTransaction = balanceAfterTransaction;
|
||||
if (party != null && party.getOffice() != null) {
|
||||
this.officeId = party.getOffice().getId();
|
||||
} else if (agent != null && agent.getOffice() != null) {
|
||||
this.officeId = agent.getOffice().getId();
|
||||
} else if (agent != null && agent.getClient() != null && agent.getClient().getOffice() != null) {
|
||||
// An agent saved without a branch is still a client of one.
|
||||
this.officeId = agent.getClient().getOffice().getId();
|
||||
}
|
||||
this.submittedOnDate = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
/** Names the journal this line was booked under, where the caller knows it better than the operation's last one. */
|
||||
public PartyTransaction linkJournal(final String transactionId) {
|
||||
this.journalTransactionId = transactionId;
|
||||
return this;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
private void linkToJournal() {
|
||||
if (this.refNo == null) {
|
||||
this.refNo = "DDST" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
|
||||
}
|
||||
if (this.journalTransactionId == null) {
|
||||
this.journalTransactionId = DdsVoucherScope.lastLedgerTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-20
@@ -48,8 +48,6 @@ public final class DdsVoucherScope {
|
||||
private final Set<String> ledgerTransactionIds = new LinkedHashSet<>();
|
||||
private final List<Long> partyTransactionIds = new ArrayList<>();
|
||||
private final List<Line> extraLines = new ArrayList<>();
|
||||
/** The journal most recently written in this operation - what the next passbook line saved belongs to. */
|
||||
private String lastLedgerTransactionId;
|
||||
|
||||
private DdsVoucherScope() {}
|
||||
|
||||
@@ -74,24 +72,6 @@ public final class DdsVoucherScope {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
if (scope != null && transactionId != null) {
|
||||
scope.ledgerTransactionIds.add(transactionId);
|
||||
scope.lastLedgerTransactionId = transactionId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The journal the passbook line being saved now belongs to: every DDS movement writes its journal
|
||||
* first and its passbook line straight after, so it is the last one written in this operation.
|
||||
*/
|
||||
public static String lastLedgerTransaction() {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
return scope == null ? null : scope.lastLedgerTransactionId;
|
||||
}
|
||||
|
||||
/** For a movement that writes no journal, so its passbook line does not claim the previous one's. */
|
||||
public static void forgetLastLedgerTransaction() {
|
||||
final DdsVoucherScope scope = CURRENT.get();
|
||||
if (scope != null) {
|
||||
scope.lastLedgerTransactionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
+6
-9
@@ -303,13 +303,9 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
+ "join m_savings_product p on p.id = a.product_id "
|
||||
+ "where a.account_no = ? and a.status_enum = 300";
|
||||
|
||||
/**
|
||||
* Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
|
||||
* Read off the parties themselves rather than a configured product id: what makes an account a
|
||||
* scheme account is that a scheme runs on it, whatever product it was opened under.
|
||||
*/
|
||||
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
|
||||
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
|
||||
/** The product DDS opens customers' scheme accounts under - see 0492. */
|
||||
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
|
||||
@@ -357,8 +353,9 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
|
||||
// A DDS scheme account is a savings account too, so the check above lets it through - but it
|
||||
// belongs to a customer's scheme, and commission paid into it would be counted as that
|
||||
// customer's deposits by every DDS interest run from then on.
|
||||
final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
|
||||
if (schemes != null && schemes > 0) {
|
||||
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -44,8 +44,6 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
// A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload.
|
||||
private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION");
|
||||
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
|
||||
|
||||
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
|
||||
@@ -95,7 +93,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
|
||||
.zeroOrPositiveAmount();
|
||||
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
|
||||
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
.notNull().zeroOrPositiveAmount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +134,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
|
||||
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
|
||||
v.reset().parameter("commissionPercent")
|
||||
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
|
||||
.zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
.zeroOrPositiveAmount();
|
||||
}
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
|
||||
-103
@@ -21,21 +21,16 @@ package org.apache.fineract.organisation.commissionslab.service;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
|
||||
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
|
||||
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
|
||||
@@ -73,14 +68,6 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
slabs.add(slab);
|
||||
}
|
||||
|
||||
// The chart each type will have once these are added - what is stored plus this request -
|
||||
// must still read as one unbroken scale from 0.
|
||||
for (final String type : slabs.stream().map(CommissionSlab::getSlabType).distinct().toList()) {
|
||||
final List<CommissionSlab> chart = new ArrayList<>(this.commissionSlabRepository.findByType(type));
|
||||
chart.addAll(slabs.stream().filter(s -> s.getSlabType().equals(type)).toList());
|
||||
rejectBrokenChart(type, chart);
|
||||
}
|
||||
|
||||
this.commissionSlabRepository.saveAllAndFlush(slabs);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
@@ -106,9 +93,6 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
|
||||
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
|
||||
.orElseThrow(() -> new CommissionSlabNotFoundException(slabId));
|
||||
final String oldType = slab.getSlabType();
|
||||
final BigDecimal oldFrom = slab.getSlabFrom();
|
||||
final BigDecimal oldTo = slab.getSlabTo();
|
||||
final Map<String, Object> changes = slab.update(command);
|
||||
|
||||
// Re-checked after the change, against every other slab of its type. A correction can just as
|
||||
@@ -117,38 +101,8 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
if (!changes.isEmpty()) {
|
||||
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
|
||||
.filter(other -> !other.getId().equals(slab.getId())).toList();
|
||||
|
||||
// A boundary moved within the same chart takes its neighbour with it, so the two slabs still
|
||||
// meet a paisa apart and the chart stays unbroken: 10,000 -> 12,000 on this slab's To moves
|
||||
// the next slab's From from 10,000.01 to 12,000.01.
|
||||
final List<CommissionSlab> moved = new ArrayList<>();
|
||||
if (oldType.equals(slab.getSlabType())) {
|
||||
for (final CommissionSlab other : others) {
|
||||
if (slab.getSlabTo().compareTo(oldTo) != 0 && other.getSlabFrom().compareTo(oldTo.add(PAISA)) == 0) {
|
||||
other.startAt(slab.getSlabTo().add(PAISA));
|
||||
moved.add(other);
|
||||
}
|
||||
if (slab.getSlabFrom().compareTo(oldFrom) != 0 && other.getSlabTo().compareTo(oldFrom.subtract(PAISA)) == 0) {
|
||||
other.endAt(slab.getSlabFrom().subtract(PAISA));
|
||||
moved.add(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rejectIfOverlapping(slab, others);
|
||||
final List<CommissionSlab> chart = new ArrayList<>(others);
|
||||
chart.add(slab);
|
||||
rejectBrokenChart(slab.getSlabType(), chart);
|
||||
if (!oldType.equals(slab.getSlabType())) {
|
||||
// Moved to the other type: the chart it left must still be whole too.
|
||||
rejectBrokenChart(oldType, this.commissionSlabRepository.findByType(oldType).stream()
|
||||
.filter(other -> !other.getId().equals(slab.getId())).toList());
|
||||
}
|
||||
this.commissionSlabRepository.saveAll(moved);
|
||||
this.commissionSlabRepository.saveAndFlush(slab);
|
||||
if (!moved.isEmpty()) {
|
||||
changes.put("neighboursAdjusted", moved.size());
|
||||
}
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
|
||||
@@ -162,63 +116,6 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
|
||||
* explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a
|
||||
* slab ending at 50000 and the next starting at 50000 both match exactly 50000.
|
||||
*/
|
||||
/** Collections are rupees and paise, so consecutive slabs meet one paisa apart. */
|
||||
private static final BigDecimal PAISA = new BigDecimal("0.01");
|
||||
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
||||
|
||||
/**
|
||||
* A chart the commission run can always read: every slab From <= To, a percentage of 0 to 100, money
|
||||
* to the paisa, starting at 0, and each slab beginning exactly a paisa after the one before ends.
|
||||
*
|
||||
* The run matches from <= collection <= to and stops with "no commission slab" for an agent whose
|
||||
* collection no slab covers - so a gap, or a chart starting above 0, is not a gentler setting, it is
|
||||
* a run that fails. Refused here, when the chart is saved, rather than discovered on payday.
|
||||
*/
|
||||
private void rejectBrokenChart(final String type, final List<CommissionSlab> chart) {
|
||||
final List<CommissionSlab> sorted = chart.stream().sorted(Comparator.comparing(CommissionSlab::getSlabFrom)).toList();
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
for (int i = 0; i < sorted.size(); i++) {
|
||||
final CommissionSlab slab = sorted.get(i);
|
||||
final String range = plain(slab.getSlabFrom()) + " - " + plain(slab.getSlabTo());
|
||||
if (slab.getSlabFrom().compareTo(slab.getSlabTo()) > 0) {
|
||||
errors.add(chartError("from.after.to", type + " slab " + range + ": From is more than To.", "slabFrom"));
|
||||
}
|
||||
if (slab.getSlabFrom().stripTrailingZeros().scale() > 2 || slab.getSlabTo().stripTrailingZeros().scale() > 2) {
|
||||
errors.add(chartError("paise", type + " slab " + range + ": amounts can have at most two decimals (paise).", "slabFrom"));
|
||||
}
|
||||
if (slab.getCommissionPercent().signum() < 0 || slab.getCommissionPercent().compareTo(HUNDRED) > 0) {
|
||||
errors.add(chartError("percent", type + " slab " + range + ": the percentage must be from 0 to 100.",
|
||||
"commissionPercent"));
|
||||
}
|
||||
if (i == 0) {
|
||||
if (slab.getSlabFrom().signum() != 0) {
|
||||
errors.add(chartError("not.from.zero", type + " slabs must start at 0 - nothing covers 0 to "
|
||||
+ plain(slab.getSlabFrom().subtract(PAISA)) + ".", "slabFrom"));
|
||||
}
|
||||
} else {
|
||||
final BigDecimal expected = sorted.get(i - 1).getSlabTo().add(PAISA);
|
||||
if (slab.getSlabFrom().compareTo(expected) > 0) {
|
||||
errors.add(chartError("gap", type + " slabs leave a gap: nothing covers " + plain(expected) + " to "
|
||||
+ plain(slab.getSlabFrom().subtract(PAISA)) + ". Start this slab at " + plain(expected) + ".",
|
||||
"slabFrom"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("error.msg.commissionslab.chart.broken",
|
||||
"The " + type + " commission chart would not cover every collection: " + errors.get(0).getDefaultUserMessage(),
|
||||
errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static ApiParameterError chartError(final String code, final String message, final String parameter) {
|
||||
return ApiParameterError.parameterError("error.msg.commissionslab." + code, message, parameter);
|
||||
}
|
||||
|
||||
private static String plain(final BigDecimal value) {
|
||||
return value.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
|
||||
for (final CommissionSlab other : existing) {
|
||||
if (!other.getSlabType().equals(candidate.getSlabType())) {
|
||||
|
||||
+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(
|
||||
|
||||
+6
-37
@@ -29,32 +29,6 @@ public final class ApprovalCounterRegistry {
|
||||
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
|
||||
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
|
||||
|
||||
/** DDS commands held for a checker, as the DDS approval queue reads them. Aliased {@code c}. */
|
||||
private static final String DDS_PENDING = "FROM m_portfolio_command_source c JOIN m_permission p "
|
||||
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true WHERE c.status = 2 ";
|
||||
|
||||
/**
|
||||
* DDS product and DDS account requests, listed under Product Approval and Pending Account with every
|
||||
* other product and account rather than under DDS Approvals or the Checker Inbox. Written for any
|
||||
* alias, since the Checker Inbox counts with {@code aud} and the DDS counts with {@code c}.
|
||||
*/
|
||||
private static String ddsProductRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'DDSACCOUNTTYPE' AND " + alias + ".action_name IN ('CREATE','UPDATE'))";
|
||||
}
|
||||
|
||||
private static String ddsAccountRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'PARTY' AND " + alias + ".action_name IN ('CREATE','UPDATE'))";
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS agent request of any kind - opening, editing or closing one. An agent has no voucher and no
|
||||
* amount, so these wait under their own Agent Approval tab rather than among the money entries of
|
||||
* DDS Approvals or the Checker Inbox.
|
||||
*/
|
||||
private static String ddsAgentRequest(final String alias) {
|
||||
return "(" + alias + ".entity_name = 'AGENT')";
|
||||
}
|
||||
|
||||
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
|
||||
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
|
||||
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
|
||||
@@ -75,9 +49,7 @@ public final class ApprovalCounterRegistry {
|
||||
// ── Fineract's own maker-checker and pending lists ────────────────────────────
|
||||
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE') "
|
||||
+ "AND NOT " + ddsProductRequest("aud") + " AND NOT " + ddsAccountRequest("aud")
|
||||
+ " AND NOT " + ddsAgentRequest("aud")),
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
|
||||
new ApprovalCounter("client-approval", "READ_CLIENT",
|
||||
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
|
||||
@@ -95,16 +67,14 @@ public final class ApprovalCounterRegistry {
|
||||
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
|
||||
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
|
||||
+ "AND COALESCE(ca.terminate_true, false) = false "
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h)) "
|
||||
+ "+ (SELECT count(*) " + DDS_PENDING + "AND " + ddsAccountRequest("c") + ")"),
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
|
||||
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
|
||||
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
|
||||
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
|
||||
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')) "
|
||||
+ "+ (SELECT count(*) " + DDS_PENDING + "AND " + ddsProductRequest("c") + ")"),
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
|
||||
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
|
||||
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
|
||||
@@ -134,10 +104,9 @@ public final class ApprovalCounterRegistry {
|
||||
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("dds-approvals", null,
|
||||
"SELECT count(*) " + DDS_PENDING + "AND c.entity_name IN (" + DDS_ENTITIES + ") "
|
||||
+ "AND NOT " + ddsProductRequest("c") + " AND NOT " + ddsAccountRequest("c")
|
||||
+ " AND NOT " + ddsAgentRequest("c")),
|
||||
new ApprovalCounter("agent-approvals", null, "SELECT count(*) " + DDS_PENDING + "AND " + ddsAgentRequest("c")),
|
||||
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
|
||||
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
|
||||
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
|
||||
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
|
||||
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
|
||||
|
||||
+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;
|
||||
|
||||
+3
-5
@@ -53,16 +53,14 @@ public class CustomerSearchApiResource {
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Search for a customer", description = "AND-combines whichever of clientId/name/mobileNo/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).")
|
||||
public Collection<CustomerSearchResultData> search(@QueryParam("clientId") final String clientId,
|
||||
@QueryParam("name") final String name, @QueryParam("mobileNo") final String mobileNo,
|
||||
@Operation(summary = "Search for a customer", description = "AND-combines whichever of name/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).")
|
||||
public Collection<CustomerSearchResultData> search(@QueryParam("name") final String name,
|
||||
@QueryParam("accountNumber") final String accountNumber, @QueryParam("aadhaarNumber") final String aadhaarNumber,
|
||||
@QueryParam("panNumber") final String panNumber, @QueryParam("address") final String address,
|
||||
@QueryParam("accountTypes") final String accountTypes) {
|
||||
final List<String> accountTypeList = accountTypes == null || accountTypes.isBlank() ? List.of()
|
||||
: Arrays.asList(accountTypes.split(","));
|
||||
return safely(() -> this.readPlatformService.search(clientId, name, mobileNo, accountNumber, aadhaarNumber, panNumber, address,
|
||||
accountTypeList));
|
||||
return safely(() -> this.readPlatformService.search(name, accountNumber, aadhaarNumber, panNumber, address, accountTypeList));
|
||||
}
|
||||
|
||||
/** Surfaces IllegalArgumentException as a clean 400 instead of an opaque 500. */
|
||||
|
||||
+3
-4
@@ -27,13 +27,12 @@ public interface CustomerSearchReadPlatformService {
|
||||
/**
|
||||
* Multi-field AND search - only non-blank parameters are applied. At least one must be
|
||||
* supplied. name/address are partial, case-insensitive matches; accountNumber/aadhaarNumber/
|
||||
* panNumber are exact matches (they're precise identifiers, not free text). clientId is an exact match
|
||||
* on the client's id and mobileNo a partial match on their mobile number. accountTypes, if
|
||||
* panNumber are exact matches (they're precise identifiers, not free text). accountTypes, if
|
||||
* non-empty, restricts results to those account types only (SAVINGS/CURRENT/FIXED_DEPOSIT/
|
||||
* RECURRING_DEPOSIT) - a client with no account, or only accounts of other types, is excluded
|
||||
* entirely when this filter is active (unlike the unfiltered case, where a client with no
|
||||
* account at all still appears once for informational purposes).
|
||||
*/
|
||||
Collection<CustomerSearchResultData> search(String clientId, String name, String mobileNo, String accountNumber, String aadhaarNumber,
|
||||
String panNumber, String address, List<String> accountTypes);
|
||||
Collection<CustomerSearchResultData> search(String name, String accountNumber, String aadhaarNumber, String panNumber,
|
||||
String address, List<String> accountTypes);
|
||||
}
|
||||
|
||||
+4
-28
@@ -69,11 +69,8 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
""";
|
||||
|
||||
@Override
|
||||
public Collection<CustomerSearchResultData> search(final String clientId, final String name, final String mobileNo,
|
||||
final String accountNumber, final String aadhaarNumber, final String panNumber, final String address,
|
||||
final List<String> accountTypes) {
|
||||
final boolean hasClientId = clientId != null && !clientId.isBlank();
|
||||
final boolean hasMobile = mobileNo != null && !mobileNo.isBlank();
|
||||
public Collection<CustomerSearchResultData> search(final String name, final String accountNumber, final String aadhaarNumber,
|
||||
final String panNumber, final String address, final List<String> accountTypes) {
|
||||
final boolean hasName = name != null && !name.isBlank();
|
||||
final boolean hasAccountNumber = accountNumber != null && !accountNumber.isBlank();
|
||||
final boolean hasAadhaar = aadhaarNumber != null && !aadhaarNumber.isBlank();
|
||||
@@ -82,12 +79,9 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
final List<String> effectiveAccountTypes = accountTypes == null ? List.of()
|
||||
: accountTypes.stream().filter(t -> t != null && !t.isBlank()).map(String::trim).map(String::toUpperCase).toList();
|
||||
|
||||
if (!hasClientId && !hasMobile && !hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress
|
||||
&& effectiveAccountTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Enter at least one search field - client ID, name, mobile number, account number, Aadhaar, PAN, or address.");
|
||||
if (!hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress && effectiveAccountTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException("Enter at least one search field - name, account number, Aadhaar, PAN, or address.");
|
||||
}
|
||||
final Long clientIdValue = hasClientId ? parseClientId(clientId) : null;
|
||||
for (final String type : effectiveAccountTypes) {
|
||||
if (!VALID_ACCOUNT_TYPES.contains(type)) {
|
||||
throw new IllegalArgumentException(
|
||||
@@ -98,19 +92,10 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
final StringBuilder sql = new StringBuilder(BASE_SELECT).append(" WHERE 1=1 ");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
|
||||
if (hasClientId) {
|
||||
sql.append(" AND c.id = ? ");
|
||||
params.add(clientIdValue);
|
||||
}
|
||||
if (hasName) {
|
||||
sql.append(" AND LOWER(c.display_name) LIKE LOWER(?) ");
|
||||
params.add("%" + name.trim() + "%");
|
||||
}
|
||||
if (hasMobile) {
|
||||
// Partial, so the last digits of a number find it as well as the whole of it.
|
||||
sql.append(" AND c.mobile_no LIKE ? ");
|
||||
params.add("%" + mobileNo.trim() + "%");
|
||||
}
|
||||
if (hasAccountNumber) {
|
||||
sql.append(" AND acct.account_no = ? ");
|
||||
params.add(accountNumber.trim());
|
||||
@@ -146,15 +131,6 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
|
||||
return this.jdbcTemplate.query(sql.toString(), new CustomerSearchResultMapper(), params.toArray());
|
||||
}
|
||||
|
||||
/** A client id is a whole number; anything else is the caller's mistake to be told about, not a server error. */
|
||||
private static Long parseClientId(final String clientId) {
|
||||
try {
|
||||
return Long.valueOf(clientId.trim());
|
||||
} catch (final NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Client ID must be a number.");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CustomerSearchResultMapper implements RowMapper<CustomerSearchResultData> {
|
||||
@Override
|
||||
public CustomerSearchResultData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
|
||||
+145
-348
@@ -19,13 +19,9 @@
|
||||
package org.apache.fineract.portfolio.dds.accounting;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
@@ -87,16 +83,35 @@ import org.springframework.stereotype.Component;
|
||||
@RequiredArgsConstructor
|
||||
public class DdsLedger {
|
||||
|
||||
/** Cash an agent has collected but not yet handed to a branch. Set by 0493. */
|
||||
public static final String AGENT_CASH = "dds-agent-cash-account";
|
||||
|
||||
/** What an agent's commission costs the bank. Set by 0491. */
|
||||
public static final String COMMISSION_EXPENSE = "dds-agent-commission-expense-account";
|
||||
|
||||
/** The savings product DDS opens customer accounts under. Set by 0492. */
|
||||
public static final String DEPOSIT_PRODUCT = "dds-deposit-product";
|
||||
|
||||
/** What DDS customers are owed on accounts that are DDS's own rather than savings accounts. Set by 0516. */
|
||||
public static final String DEPOSIT_LIABILITY = "dds-deposit-liability-account";
|
||||
|
||||
/** What interest on DDS's own accounts costs the bank. Set by 0518. */
|
||||
public static final String INTEREST_EXPENSE = "dds-interest-expense-account";
|
||||
|
||||
private static final String ORGANISATION_CURRENCY_SQL = "select code from m_organisation_currency order by id limit 1";
|
||||
|
||||
private static final String PRODUCT_CURRENCY_SQL = "select currency_code from m_savings_product where id = ?";
|
||||
|
||||
private static final String CONFIGURED_ACCOUNT_SQL = "select value from c_configuration where name = ? and enabled = true";
|
||||
|
||||
/**
|
||||
* The accounts money can be parked in for a customer with no account here. Every bank names its
|
||||
* Sundry account differently, so none is assumed: every open posting liability account is offered
|
||||
* and the clerk searches it by code or name and picks their bank's own. Liability, because the bank
|
||||
* now owes the money - an income or expense account would book it as the bank's own. Posting,
|
||||
* because a header cannot take an entry. And never an account whose balance is meant to equal the
|
||||
* sum of customer or agent accounts - a DDS product's customer deposits account, a savings or
|
||||
* deposit product's control account, an agent's own cash account - since parked money in any of
|
||||
* sum of customer or agent accounts - DDS Customer Deposits, a savings or deposit product's control
|
||||
* account, a current-account control, an agent's own cash account - since parked money in any of
|
||||
* those would leave it no longer adding up.
|
||||
*/
|
||||
private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" "
|
||||
@@ -105,7 +120,8 @@ public class DdsLedger {
|
||||
+ "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 "
|
||||
+ "and m.financial_account_type = 2 and m.gl_account_id is not null) "
|
||||
+ "and g.id not in (select a.gl_account_id from m_dds_agent a where a.gl_account_id is not null) "
|
||||
+ "and g.id not in (select t.deposit_liability_gl_id from m_dds_account_type t where t.deposit_liability_gl_id is not null) "
|
||||
+ "and g.id not in (select c.value from c_configuration c where c.name in ('dds-deposit-liability-account', "
|
||||
+ "'dds-current-account-control-account', 'dds-agent-cash-account') and c.value is not null) "
|
||||
+ "order by g.gl_code";
|
||||
|
||||
/**
|
||||
@@ -148,7 +164,7 @@ public class DdsLedger {
|
||||
* Deposits, so a transfer between them writes no leg, yet the slip still has to name both.
|
||||
*/
|
||||
public void voucherSideOnDeposits(final String entryType, final Party party, final BigDecimal amount, final String description) {
|
||||
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, depositLiabilityOf(party).getId(),
|
||||
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, configured(DEPOSIT_LIABILITY).getId(),
|
||||
party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description));
|
||||
}
|
||||
|
||||
@@ -167,6 +183,17 @@ public class DdsLedger {
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves cash from an agent's hands into a branch. No customer account is involved - the customers
|
||||
* were credited when their money was collected; this is the bank catching up with the cash.
|
||||
*/
|
||||
public void settle(final Office office, final LocalDate date, final BigDecimal amount, final String description,
|
||||
final GLAccount agentCashAccount) {
|
||||
final Long productId = depositProductId();
|
||||
postPair(office, currencyOf(productId), date, amount, productAccount(productId, FUND_SOURCE), agentCashOr(agentCashAccount),
|
||||
description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the customers of one application their money from the cashier's DDS receipts, in one entry.
|
||||
*
|
||||
@@ -180,13 +207,11 @@ public class DdsLedger {
|
||||
* DR the account the receipts credited the agent's account clears
|
||||
* CR DDS Customer Deposits what the bank now owes the customers
|
||||
* </pre>
|
||||
*
|
||||
* One pair per customer-deposits account rather than one in all: each DDS product names its own
|
||||
* (see depositLiabilityOf), so customers on two products are owed on two accounts.
|
||||
*/
|
||||
public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount,
|
||||
final GLAccount depositAccount, final String transactionId, final String description) {
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, depositAccount, description, null, null);
|
||||
final String transactionId, final String description) {
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description,
|
||||
null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,7 +242,7 @@ public class DdsLedger {
|
||||
collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, agentCashOr(agentCashAccount), depositLiabilityOf(party), description);
|
||||
ddsPair(party, date, amount, agentCashOr(agentCashAccount), configured(DEPOSIT_LIABILITY), description);
|
||||
}
|
||||
|
||||
/** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */
|
||||
@@ -226,7 +251,7 @@ public class DdsLedger {
|
||||
postInterest(party.getSavingsAccountId(), date, amount, description);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, interestExpenseOf(party), depositLiabilityOf(party), description);
|
||||
ddsPair(party, date, amount, configured(INTEREST_EXPENSE), configured(DEPOSIT_LIABILITY), description);
|
||||
}
|
||||
|
||||
/** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */
|
||||
@@ -235,90 +260,7 @@ public class DdsLedger {
|
||||
reverseInterest(party.getSavingsAccountId(), date, amount, description);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, depositLiabilityOf(party), interestExpenseOf(party), description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest a customer gives back on closing early, as the bank's income: the interest was paid and
|
||||
* stays an expense, and what comes back is earned for the closure.
|
||||
*
|
||||
* <pre>
|
||||
* DR DDS Customer Deposits the customer's balance comes down
|
||||
* CR Premature Closure Income the product's premature-closure income
|
||||
* </pre>
|
||||
*/
|
||||
public void recoverPrematureInterest(final Party party, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
final SavingsAccount account = load(party.getSavingsAccountId());
|
||||
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), prematureIncomeOf(party),
|
||||
description, withdrawal.getId(), account.getId());
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, depositLiabilityOf(party), prematureIncomeOf(party), description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interest an agent funded on an early closure, now paid for out of their commission. The bank booked
|
||||
* it as interest on the closing day; the commission it came off was booked net. So it moves across:
|
||||
*
|
||||
* <pre>
|
||||
* DR Agent Commission Expense
|
||||
* CR Interest Expense
|
||||
* </pre>
|
||||
*/
|
||||
public void bookAgentInterestTopUpRecovery(final Party party, final LocalDate date, final BigDecimal amount,
|
||||
final String description) {
|
||||
ddsPair(party, date, amount, commissionExpenseOf(party), interestExpenseOf(party), description);
|
||||
}
|
||||
|
||||
private static final String TDS_BASIS_SQL = "select upper(trim(p.sub_type)) as \"product\", t.commission_expense_gl_id as \"commissionGlId\", "
|
||||
+ "t.tds_payable_gl_id as \"tdsGlId\", sum(pt.amount) as \"volume\" from m_dds_party_transaction pt "
|
||||
+ "join m_dds_party p on p.id = pt.party_id left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
|
||||
+ "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' and pt.commission_paid_batch_run_id = ? "
|
||||
+ "group by upper(trim(p.sub_type)), t.commission_expense_gl_id, t.tds_payable_gl_id order by upper(trim(p.sub_type))";
|
||||
|
||||
/**
|
||||
* TDS withheld from an agent's commission (Section 194H), booked when the commission it was withheld
|
||||
* from is paid. The agent is paid net; the TDS is commission the bank owes the tax department instead,
|
||||
* so it is commission expense like the rest, credited to TDS Payable - shared over the products by
|
||||
* what was collected for each, as the commission is.
|
||||
*
|
||||
* <pre>
|
||||
* DR Agent Commission Expense
|
||||
* CR TDS Payable
|
||||
* </pre>
|
||||
*/
|
||||
public void bookCommissionTds(final Office office, final Long agentId, final Long payingRunId, final LocalDate date,
|
||||
final BigDecimal tds, final String description) {
|
||||
if (tds == null || tds.signum() <= 0) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> basis = this.jdbcTemplate.queryForList(TDS_BASIS_SQL, agentId, payingRunId); // NOSONAR
|
||||
if (basis.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("a collection paid by this commission run to tell which DDS product's TDS Payable "
|
||||
+ "account the TDS belongs to");
|
||||
}
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : basis) {
|
||||
total = total.add((BigDecimal) row.get("volume"));
|
||||
}
|
||||
BigDecimal allotted = BigDecimal.ZERO;
|
||||
for (int i = 0; i < basis.size(); i++) {
|
||||
final Map<String, Object> row = basis.get(i);
|
||||
final String product = (String) row.get("product");
|
||||
final BigDecimal share = i == basis.size() - 1 || total.signum() == 0 ? tds.subtract(allotted)
|
||||
: tds.multiply((BigDecimal) row.get("volume")).divide(total, 2, RoundingMode.HALF_UP);
|
||||
allotted = allotted.add(share);
|
||||
if (share.signum() <= 0) {
|
||||
continue;
|
||||
}
|
||||
final GLAccount commission = accountNamed(row.get("commissionGlId") == null ? null : ((Number) row.get("commissionGlId")).longValue(),
|
||||
product, "Agent Commission Expense");
|
||||
final GLAccount tdsPayable = accountNamed(row.get("tdsGlId") == null ? null : ((Number) row.get("tdsGlId")).longValue(), product,
|
||||
"TDS Payable");
|
||||
postPair(office, organisationCurrency(), date, share, commission, tdsPayable, description, null, null);
|
||||
}
|
||||
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), configured(INTEREST_EXPENSE), description);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,7 +275,7 @@ public class DdsLedger {
|
||||
}
|
||||
final SavingsAccount to = load(toSavingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
|
||||
postPair(to, deposit, date, amount, depositLiabilityOf(party), controlAccountOf(to), description);
|
||||
postPair(to, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(to), description);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,23 +295,24 @@ public class DdsLedger {
|
||||
final String description) {
|
||||
final GLAccount owed;
|
||||
final Office office;
|
||||
Long withdrawalId = null;
|
||||
Long savingsAccountId = null;
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
final SavingsAccount account = load(party.getSavingsAccountId());
|
||||
withdrawalId = withdraw(account, date, amount).getId();
|
||||
savingsAccountId = account.getId();
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
owed = controlAccountOf(account);
|
||||
office = account.office();
|
||||
} else {
|
||||
owed = depositLiabilityOf(party);
|
||||
office = officeOf(party);
|
||||
owed = configured(DEPOSIT_LIABILITY);
|
||||
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||
}
|
||||
|
||||
creditCurrentAccount(currentAccountId, date, amount, description);
|
||||
final String transactionId = withdrawalId != null ? "S" + withdrawalId : partyLegId(party);
|
||||
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description,
|
||||
withdrawalId, savingsAccountId);
|
||||
null, null);
|
||||
}
|
||||
|
||||
private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?";
|
||||
@@ -386,6 +329,9 @@ public class DdsLedger {
|
||||
+ " join acc_product_mapping apm on apm.product_id = ca.product_id "
|
||||
+ " where ca.id = ? and apm.product_type = 2 and apm.financial_account_type = 2 and apm.payment_type is null";
|
||||
|
||||
private static final String CURRENT_CONTROL_OVERRIDE_SQL = "select value from c_configuration "
|
||||
+ " where name = 'dds-current-account-control-account' and enabled = true";
|
||||
|
||||
/** A deposit row and the balances on the current account, refused unless it is active. */
|
||||
private void creditCurrentAccount(final Long currentAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR
|
||||
@@ -402,16 +348,23 @@ public class DdsLedger {
|
||||
this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR
|
||||
}
|
||||
|
||||
/** The current account's control account, from the current product's own accounting. */
|
||||
/**
|
||||
* The current account's control account: its product's mapping when that is the only one, and the
|
||||
* setting when the product shares an id - and so its mapping rows - with a savings product.
|
||||
*/
|
||||
private GLAccount currentAccountControl(final Long currentAccountId) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR
|
||||
if (ids.size() == 1) {
|
||||
return this.glAccountRepository.findById(ids.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId));
|
||||
}
|
||||
final List<Long> override = this.jdbcTemplate.queryForList(CURRENT_CONTROL_OVERRIDE_SQL, Long.class); // NOSONAR
|
||||
if (!override.isEmpty() && override.get(0) != null) {
|
||||
return this.glAccountRepository.findById(override.get(0))
|
||||
.orElseThrow(() -> new DdsLedgerNotConfiguredException("dds-current-account-control-account"));
|
||||
}
|
||||
throw new DdsLedgerNotConfiguredException(ids.isEmpty() ? "control account for current account " + currentAccountId
|
||||
: "a single control account for current account " + currentAccountId
|
||||
+ " (its current product's GL mapping is shared with a savings product of the same id)");
|
||||
: "dds-current-account-control-account (the current product's GL mapping is shared with a savings product of the same id)");
|
||||
}
|
||||
|
||||
/** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */
|
||||
@@ -421,7 +374,7 @@ public class DdsLedger {
|
||||
payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn);
|
||||
return;
|
||||
}
|
||||
ddsPair(party, date, amount, depositLiabilityOf(party), parkIn, description);
|
||||
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), parkIn, description);
|
||||
}
|
||||
|
||||
/** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */
|
||||
@@ -464,201 +417,44 @@ public class DdsLedger {
|
||||
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
|
||||
} else if (fromSavings) {
|
||||
final SavingsAccount account = load(from.getSavingsAccountId());
|
||||
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||
depositLiabilityOf(from), description, withdrawal.getId(), account.getId());
|
||||
configured(DEPOSIT_LIABILITY), description, null, account.getId());
|
||||
} else if (toSavings) {
|
||||
final SavingsAccount account = load(to.getSavingsAccountId());
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
postPair(account, deposit, date, amount, depositLiabilityOf(to), controlAccountOf(account), description);
|
||||
postPair(account, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(account), description);
|
||||
} else {
|
||||
final GLAccount fromDeposits = depositLiabilityOf(from);
|
||||
final GLAccount toDeposits = depositLiabilityOf(to);
|
||||
if (fromDeposits.getId().equals(toDeposits.getId())) {
|
||||
// Both owed on the same account: nothing to book, but the slip still names who paid whom -
|
||||
// and the two passbook lines claim no journal, since none was written for them.
|
||||
DdsVoucherScope.forgetLastLedgerTransaction();
|
||||
// Both on DDS Customer Deposits: nothing to book, but the slip still names who paid whom.
|
||||
voucherSideOnDeposits("DR", from, amount, description);
|
||||
voucherSideOnDeposits("CR", to, amount, description);
|
||||
} else {
|
||||
// Two products with customer-deposit accounts of their own: the debt moves from one to the other.
|
||||
ddsPair(from, date, amount, fromDeposits, toDeposits, description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */
|
||||
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
|
||||
final String description) {
|
||||
writePair(officeOf(party), organisationCurrency(), partyLegId(party), date, amount, debit, credit, description, null, null);
|
||||
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account playing the agent's part on an entry - the agent's own. An agent cannot be saved
|
||||
* without one, so a missing one here is an agent set up before that was required.
|
||||
* Which account plays the agent's part on an entry: the agent's own, or the scheme's shared one
|
||||
* when they have none.
|
||||
*
|
||||
* Only the account differs. Both sides of the entry, their direction and the amount are the same
|
||||
* either way - an agent with their own account is not accounted for differently, only separately.
|
||||
* That is what makes the shared account a safe fallback rather than a second behaviour: an agent
|
||||
* set up before this existed posts exactly as they always did, and a branch can move agents onto
|
||||
* their own accounts one at a time.
|
||||
*/
|
||||
private GLAccount agentCashOr(final GLAccount agentCashAccount) {
|
||||
if (agentCashAccount == null) {
|
||||
throw new DdsLedgerNotConfiguredException("the agent's GL account (Organization > Agent Master)");
|
||||
}
|
||||
return agentCashAccount;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// A party's product's own ledger accounts.
|
||||
//
|
||||
// Each DDS product names the accounts it posts through on its Accounting tab (0620), as a savings
|
||||
// product does, and those are the only accounts DDS posts through - there is no bank-wide account
|
||||
// behind them. A product that has not named one stops the posting with a message saying which
|
||||
// account to set on which product, rather than the money landing somewhere nobody chose.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Columns of m_dds_account_type a product's account may be read from - never anything a caller typed. */
|
||||
private static final Set<String> PRODUCT_GL_COLUMNS = Set.of("deposit_liability_gl_id", "interest_expense_gl_id",
|
||||
"commission_expense_gl_id", "premature_income_gl_id");
|
||||
|
||||
/** What an early closure of this party's account earns the bank - their product's premature-closure income. */
|
||||
public GLAccount prematureIncomeOf(final Party party) {
|
||||
return productAccount(party, "premature_income_gl_id", "Premature Closure Income");
|
||||
}
|
||||
|
||||
/** What the agents' commission on this party's product costs the bank. */
|
||||
public GLAccount commissionExpenseOf(final Party party) {
|
||||
return productAccount(party, "commission_expense_gl_id", "Agent Commission Expense");
|
||||
}
|
||||
|
||||
/** What the bank owes this party - their product's customer-deposits account. */
|
||||
public GLAccount depositLiabilityOf(final Party party) {
|
||||
return productAccount(party, "deposit_liability_gl_id", "DDS Customer Deposits");
|
||||
}
|
||||
|
||||
/** What this party's interest costs the bank - their product's interest-expense account. */
|
||||
public GLAccount interestExpenseOf(final Party party) {
|
||||
return productAccount(party, "interest_expense_gl_id", "Interest Expense");
|
||||
}
|
||||
|
||||
private GLAccount productAccount(final Party party, final String column, final String label) {
|
||||
if (!PRODUCT_GL_COLUMNS.contains(column)) {
|
||||
throw new IllegalArgumentException(column);
|
||||
}
|
||||
final String productCode = party == null || party.getSubType() == null ? null : party.getSubType().trim().toUpperCase();
|
||||
if (productCode == null) {
|
||||
throw new DdsLedgerNotConfiguredException("a DDS product for party " + (party == null ? "?" : party.getPartyCode()));
|
||||
}
|
||||
// By the id the account is tied to; by its code only for an account saved before ids were kept.
|
||||
final List<Long> ids = party.getAccountTypeId() != null
|
||||
? this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where id = ?", Long.class, // NOSONAR
|
||||
party.getAccountTypeId())
|
||||
: this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where account_type_code = ?", // NOSONAR
|
||||
Long.class, productCode);
|
||||
if (ids.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("DDS product " + productCode + " (party " + party.getPartyCode() + ")");
|
||||
}
|
||||
return accountNamed(ids.get(0), productCode, label);
|
||||
}
|
||||
|
||||
private GLAccount accountNamed(final Long glAccountId, final String productCode, final String label) {
|
||||
final Optional<GLAccount> account = glAccountId == null ? Optional.empty() : this.glAccountRepository.findById(glAccountId);
|
||||
return account.orElseThrow(() -> new DdsLedgerNotConfiguredException(
|
||||
"the " + label + " account on DDS product " + productCode + " (Products > DDS Products > Edit > Accounting)"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// An agent's commission, by product.
|
||||
//
|
||||
// Commission is paid to an agent in one sum for a round that may collect for several products, and
|
||||
// each product books its share on its own commission account. The share is each product's part of
|
||||
// the collections the commission was earned on: those a commission run has counted and no payment
|
||||
// has yet covered. Paying marks them, so the next payment is shared out on the next collections.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private static final String COMMISSION_BASIS_SQL = "select upper(trim(p.sub_type)) as \"product\", t.commission_expense_gl_id as \"glId\", "
|
||||
+ "sum(pt.amount) as \"volume\" from m_dds_party_transaction pt join m_dds_party p on p.id = pt.party_id "
|
||||
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
|
||||
+ "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' "
|
||||
+ "and pt.commission_batch_run_id is not null and pt.commission_batch_run_id < ? %s group by upper(trim(p.sub_type)), t.commission_expense_gl_id "
|
||||
+ "order by upper(trim(p.sub_type))";
|
||||
|
||||
private static final String UNPAID_COMMISSION_FILTER = "and pt.commission_paid_batch_run_id is null";
|
||||
|
||||
private static final String MARK_COMMISSION_PAID_SQL = "update m_dds_party_transaction set commission_paid_batch_run_id = ? "
|
||||
+ "where agent_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' and commission_batch_run_id is not null "
|
||||
+ "and commission_batch_run_id < ? and commission_paid_batch_run_id is null";
|
||||
|
||||
/**
|
||||
* An agent's commission split across the commission accounts of the products they collected for,
|
||||
* in proportion to what they collected for each. The last share takes the rounding, so the shares
|
||||
* always add up to the amount paid.
|
||||
*
|
||||
* Only collections counted by commission runs before the paying run are its basis: a run counted
|
||||
* after this payment was generated earned commission this payment does not include. Collections
|
||||
* counted before payments were tracked by run have no payment marked against them; if nothing
|
||||
* unpaid is left, the agent's counted collections as a whole set the proportions instead.
|
||||
*/
|
||||
public Map<GLAccount, BigDecimal> commissionSplit(final Long agentId, final BigDecimal amount, final Long payingRunId) {
|
||||
List<Map<String, Object>> basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, UNPAID_COMMISSION_FILTER), // NOSONAR
|
||||
agentId, payingRunId);
|
||||
if (basis.isEmpty()) {
|
||||
basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, ""), agentId, payingRunId); // NOSONAR
|
||||
}
|
||||
return shareOut(basis, amount);
|
||||
}
|
||||
|
||||
/** The collections a commission run would count if it were posted now: not counted by any run yet, up to a date. */
|
||||
private static final String COMMISSION_PENDING_SQL = "select upper(trim(p.sub_type)) as \"product\", t.commission_expense_gl_id as \"glId\", "
|
||||
+ "sum(pt.amount) as \"volume\" from m_dds_party_transaction pt join m_dds_party p on p.id = pt.party_id "
|
||||
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
|
||||
+ "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' "
|
||||
+ "and pt.commission_batch_run_id is null and pt.transaction_date <= ? group by upper(trim(p.sub_type)), "
|
||||
+ "t.commission_expense_gl_id order by upper(trim(p.sub_type))";
|
||||
|
||||
/**
|
||||
* Where an agent's commission will be booked once it is posted - for a calculation run, which books
|
||||
* nothing itself but whose approval should say which commission accounts the Posting run will debit.
|
||||
* The split is over the collections the calculation counts (none counted yet, up to its end date).
|
||||
*/
|
||||
public Map<GLAccount, BigDecimal> projectedCommissionSplit(final Long agentId, final BigDecimal amount, final LocalDate through) {
|
||||
return shareOut(this.jdbcTemplate.queryForList(COMMISSION_PENDING_SQL, agentId, java.sql.Date.valueOf(through)), amount); // NOSONAR
|
||||
}
|
||||
|
||||
/** Shares an amount over the commission accounts of the products collected for, by volume; the last takes the rounding. */
|
||||
private Map<GLAccount, BigDecimal> shareOut(final List<Map<String, Object>> basis, final BigDecimal amount) {
|
||||
if (basis.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("a collection by this agent to tell which DDS product's Agent Commission Expense "
|
||||
+ "account the commission belongs to");
|
||||
}
|
||||
|
||||
final Map<Long, GLAccount> accounts = new LinkedHashMap<>();
|
||||
final Map<Long, BigDecimal> volumes = new LinkedHashMap<>();
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : basis) {
|
||||
final String product = (String) row.get("product");
|
||||
final Long glId = row.get("glId") == null ? null : ((Number) row.get("glId")).longValue();
|
||||
final GLAccount account = accountNamed(glId, product, "Agent Commission Expense");
|
||||
final BigDecimal volume = (BigDecimal) row.get("volume");
|
||||
accounts.putIfAbsent(account.getId(), account);
|
||||
volumes.merge(account.getId(), volume, BigDecimal::add);
|
||||
total = total.add(volume);
|
||||
}
|
||||
|
||||
final Map<GLAccount, BigDecimal> shares = new LinkedHashMap<>();
|
||||
BigDecimal allotted = BigDecimal.ZERO;
|
||||
final List<Long> ids = List.copyOf(volumes.keySet());
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
final Long id = ids.get(i);
|
||||
final BigDecimal share = i == ids.size() - 1 || total.signum() == 0 ? amount.subtract(allotted)
|
||||
: amount.multiply(volumes.get(id)).divide(total, 2, RoundingMode.HALF_UP);
|
||||
allotted = allotted.add(share);
|
||||
if (share.signum() != 0) {
|
||||
shares.put(accounts.get(id), share);
|
||||
}
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
|
||||
/** Marks the collections a commission payment was shared out on, so the next payment starts after them. */
|
||||
public void markCommissionPaid(final Long agentId, final Long batchRunId) {
|
||||
this.jdbcTemplate.update(MARK_COMMISSION_PAID_SQL, batchRunId, agentId, batchRunId); // NOSONAR
|
||||
return agentCashAccount != null ? agentCashAccount : configured(AGENT_CASH);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -694,9 +490,15 @@ public class DdsLedger {
|
||||
public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
|
||||
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
|
||||
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, withdrawal.getId(), account.getId());
|
||||
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -713,15 +515,15 @@ public class DdsLedger {
|
||||
|
||||
// The scheme account is blocked for debit so nobody can take the money out at a counter. This
|
||||
// movement is DDS's own, so the block comes off for it and goes straight back on.
|
||||
final SavingsAccountTransaction withdrawal = withdraw(from, date, amount);
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(from);
|
||||
final SavingsTransactionBooleanValues withdrawalFlags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(from, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, withdrawalFlags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(from, wasBlocked);
|
||||
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
|
||||
|
||||
// One leg on each account, each carrying its own account transaction, so both sides of the
|
||||
// movement can be traced from the journal - not only the deposit.
|
||||
writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(from),
|
||||
JournalEntryType.DEBIT, description, withdrawal.getId(), from.getId());
|
||||
writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(to),
|
||||
JournalEntryType.CREDIT, description, deposit.getId(), to.getId());
|
||||
postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -754,9 +556,15 @@ public class DdsLedger {
|
||||
final GLAccount parkIn) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
|
||||
final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn, description,
|
||||
withdrawal.getId(), account.getId());
|
||||
// DDS's own movement, so the debit block comes off for it and goes straight back on.
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
|
||||
this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
|
||||
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn,
|
||||
description, null, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -784,18 +592,12 @@ public class DdsLedger {
|
||||
return deposit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits an agent's own savings account with commission the bank owes them - one deposit to the
|
||||
* agent, and one entry per product on that product's commission account (see commissionSplit).
|
||||
*/
|
||||
/** Credits an agent's own savings account with commission the bank owes them. */
|
||||
public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
|
||||
final String description, final Map<GLAccount, BigDecimal> shares) {
|
||||
final String description) {
|
||||
final SavingsAccount account = load(savingsAccountId);
|
||||
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
|
||||
final GLAccount control = controlAccountOf(account);
|
||||
for (final Map.Entry<GLAccount, BigDecimal> share : shares.entrySet()) {
|
||||
postPair(account, deposit, date, share.getValue(), share.getKey(), control, description);
|
||||
}
|
||||
postPair(account, deposit, date, amount, configured(COMMISSION_EXPENSE), controlAccountOf(account), description);
|
||||
return deposit;
|
||||
}
|
||||
|
||||
@@ -865,60 +667,47 @@ public class DdsLedger {
|
||||
private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount,
|
||||
final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
|
||||
final Long savingsAccountId) {
|
||||
final String transactionId = savingsTransactionId == null ? "DDS-" + UUID.randomUUID() : "S" + savingsTransactionId;
|
||||
final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId;
|
||||
writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId);
|
||||
}
|
||||
|
||||
private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
|
||||
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
|
||||
final Long savingsTransactionId, final Long savingsAccountId) {
|
||||
writeLeg(office, currencyCode, transactionId, date, amount, debit, JournalEntryType.DEBIT, description, savingsTransactionId,
|
||||
savingsAccountId);
|
||||
writeLeg(office, currencyCode, transactionId, date, amount, credit, JournalEntryType.CREDIT, description, savingsTransactionId,
|
||||
savingsAccountId);
|
||||
}
|
||||
|
||||
private void writeLeg(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
|
||||
final BigDecimal amount, final GLAccount account, final JournalEntryType type, final String description,
|
||||
final Long savingsTransactionId, final Long savingsAccountId) {
|
||||
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService - and as the
|
||||
// journal the next passbook line of the operation is linked to (m_dds_party_transaction.journal_transaction_id).
|
||||
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService.
|
||||
DdsVoucherScope.recordLedgerTransaction(transactionId);
|
||||
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null, account, currencyCode, transactionId, false, date, type,
|
||||
amount, description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null,
|
||||
null));
|
||||
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) {
|
||||
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null,
|
||||
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
|
||||
description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A journal id for a leg on DDS's own books - the party's code to find it by and a full UUID to keep it
|
||||
* unique: DDS-PT0003-1b4e... Never built from the date and amount, which two parties can share.
|
||||
*/
|
||||
private static String partyLegId(final Party party) {
|
||||
return "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
/** A ledger account named by a setting, so a bank can point it at one of their own. */
|
||||
private GLAccount configured(final String settingName) {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, settingName); // NOSONAR
|
||||
if (ids.isEmpty() || ids.get(0) == null) {
|
||||
throw new DdsLedgerNotConfiguredException(settingName);
|
||||
}
|
||||
|
||||
/** The branch a party's legs are booked at: the party's own, or its agent's for a party opened without one. */
|
||||
public Office officeOf(final Party party) {
|
||||
return party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes money off a scheme account that DDS keeps blocked for debits, as DDS's own movement - the block
|
||||
* comes off for it and goes straight back on - and returns the withdrawal so its journal can name it.
|
||||
*/
|
||||
private SavingsAccountTransaction withdraw(final SavingsAccount account, final LocalDate date, final BigDecimal amount) {
|
||||
final boolean wasBlocked = unlockDebitsIfBlocked(account);
|
||||
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
|
||||
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(account,
|
||||
DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, this.savingsAccountAssembler.getPivotConfigStatus());
|
||||
relockDebits(account, wasBlocked);
|
||||
return withdrawal;
|
||||
return this.glAccountRepository.findById(ids.get(0)).orElseThrow(() -> new DdsLedgerNotConfiguredException(settingName));
|
||||
}
|
||||
|
||||
private GLAccount controlAccountOf(final SavingsAccount account) {
|
||||
return productAccount(account.productId(), CONTROL_ACCOUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The savings product DDS opens customer accounts under. A settlement touches no single customer,
|
||||
* so its ledger accounts and currency come from the product rather than from an account.
|
||||
*/
|
||||
private Long depositProductId() {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, DEPOSIT_PRODUCT); // NOSONAR
|
||||
if (ids.isEmpty() || ids.get(0) == null) {
|
||||
throw new DdsLedgerNotConfiguredException(DEPOSIT_PRODUCT);
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
/** The bank's own currency, for an entry that belongs to no savings product. */
|
||||
private String organisationCurrency() {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR
|
||||
@@ -928,6 +717,14 @@ public class DdsLedger {
|
||||
return codes.get(0);
|
||||
}
|
||||
|
||||
/** Read off the product rather than assumed, so a bank on another currency is not silently wrong. */
|
||||
private String currencyOf(final Long productId) {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList(PRODUCT_CURRENCY_SQL, String.class, productId); // NOSONAR
|
||||
if (codes.isEmpty()) {
|
||||
throw new DdsLedgerNotConfiguredException("currency for savings product " + productId);
|
||||
}
|
||||
return codes.get(0);
|
||||
}
|
||||
|
||||
/** An account off the savings product's own mapping, so this stays right per product. */
|
||||
private GLAccount productAccount(final Long productId, final int financialAccountType) {
|
||||
|
||||
+7
-24
@@ -22,7 +22,6 @@ import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -336,9 +335,6 @@ public class DdsVoucherService {
|
||||
LAST_ISSUED.set(voucherNo);
|
||||
|
||||
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
|
||||
// The journal legs carry the voucher too, in ref_num, so the slip, the passbook lines and the
|
||||
// journal can each be found from the others - as a savings voucher's legs can.
|
||||
stampLegs(voucherNo, scope.getLedgerTransactionIds());
|
||||
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
|
||||
savingsTransactions);
|
||||
}
|
||||
@@ -360,7 +356,7 @@ public class DdsVoucherService {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
final Set<Long> depositGlIds = depositLiabilityGlIds();
|
||||
final Long depositGlId = depositLiabilityGlId();
|
||||
final Map<String, Map<String, Object>> parties = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR
|
||||
transactionIds.toArray())) {
|
||||
@@ -387,7 +383,7 @@ public class DdsVoucherService {
|
||||
}
|
||||
|
||||
final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId")));
|
||||
if (partyLeg.matches() && depositGlIds.contains(glId)) {
|
||||
if (partyLeg.matches() && glId.equals(depositGlId)) {
|
||||
final String partyCode = partyLeg.group(1);
|
||||
final Map<String, Object> party = parties.computeIfAbsent(partyCode,
|
||||
code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR
|
||||
@@ -421,24 +417,11 @@ public class DdsVoucherService {
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The GLs that carry what DDS customers are owed - a leg on one is the customer's own. Each DDS
|
||||
* product names its own on its Accounting tab (0620).
|
||||
*/
|
||||
private Set<Long> depositLiabilityGlIds() {
|
||||
return new HashSet<>(this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select deposit_liability_gl_id from m_dds_account_type where deposit_liability_gl_id is not null", Long.class));
|
||||
}
|
||||
|
||||
private void stampLegs(final String voucherNo, final Collection<String> transactionIds) {
|
||||
if (transactionIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final List<Object> args = new ArrayList<>();
|
||||
args.add(voucherNo);
|
||||
args.addAll(transactionIds);
|
||||
this.jdbcTemplate.update("update acc_gl_journal_entry set ref_num = ? where ref_num is null and transaction_id in (" // NOSONAR
|
||||
+ placeholders(transactionIds) + ")", args.toArray());
|
||||
/** The GL that carries what DDS customers are owed - a leg on it is the customer's own. */
|
||||
private Long depositLiabilityGlId() {
|
||||
final List<Long> ids = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select value from c_configuration where name = 'dds-deposit-liability-account' and enabled = true", Long.class);
|
||||
return ids.isEmpty() ? null : ids.get(0);
|
||||
}
|
||||
|
||||
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
|
||||
|
||||
+6
-2
@@ -27,7 +27,11 @@ public interface DdsAccountTypeRepository extends JpaRepository<DdsAccountType,
|
||||
|
||||
Optional<DdsAccountType> findByAccountTypeCode(String accountTypeCode);
|
||||
|
||||
/** Schemes that post interest on their own, at the end of each posting period. */
|
||||
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true order by t.accountTypeCode")
|
||||
/**
|
||||
* Schemes that post interest on their own. A posting day is required to act on one, so a scheme
|
||||
* left without a day is not picked up - it would otherwise mean "post monthly, some day".
|
||||
*/
|
||||
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true "
|
||||
+ "and t.autoInterestPostDay is not null order by t.accountTypeCode")
|
||||
List<DdsAccountType> findDueForAutoInterestPost();
|
||||
}
|
||||
|
||||
+5
-29
@@ -39,15 +39,9 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
|
||||
/*
|
||||
* "autoInterestPostDay" is still accepted, and ignored, so a change held for approval before the
|
||||
* posting day was retired - or an older screen still sending it - is not refused. Interest now
|
||||
* posts at the end of each period, so there is no day to set.
|
||||
*/
|
||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
|
||||
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
|
||||
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
|
||||
"depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId", "prematureIncomeGlId", "tdsPayableGlId",
|
||||
"dateFormat", "locale"));
|
||||
|
||||
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
|
||||
@@ -72,20 +66,6 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
v.reset().parameter("accountTypeDescription")
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
|
||||
.notExceedingLengthOf(200);
|
||||
// Mandatory, as a savings product's control and interest accounts are: every posting for the
|
||||
// product's customers and agents goes through them, with nothing bank-wide behind them.
|
||||
v.reset().parameter("depositLiabilityGlId").value(this.fromApiJsonHelper.extractLongNamed("depositLiabilityGlId", element))
|
||||
.notNull().integerGreaterThanZero();
|
||||
v.reset().parameter("interestExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("interestExpenseGlId", element))
|
||||
.notNull().integerGreaterThanZero();
|
||||
v.reset().parameter("commissionExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("commissionExpenseGlId", element))
|
||||
.notNull().integerGreaterThanZero();
|
||||
// What an early closure earns the bank - the customer's interest given back and the agent's penalty.
|
||||
v.reset().parameter("prematureIncomeGlId").value(this.fromApiJsonHelper.extractLongNamed("prematureIncomeGlId", element))
|
||||
.notNull().integerGreaterThanZero();
|
||||
// Where the TDS withheld from the agents' commission (Section 194H) is owed until it is paid over.
|
||||
v.reset().parameter("tdsPayableGlId").value(this.fromApiJsonHelper.extractLongNamed("tdsPayableGlId", element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
// Mandatory: every account opened on the scheme takes this rate.
|
||||
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
|
||||
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
@@ -119,15 +99,6 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
|
||||
}
|
||||
|
||||
// Once named, a product's customer and interest accounts can be changed but not cleared.
|
||||
for (final String required : List.of("depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId",
|
||||
"prematureIncomeGlId", "tdsPayableGlId")) {
|
||||
if (this.fromApiJsonHelper.parameterExists(required, element)) {
|
||||
v.reset().parameter(required).value(this.fromApiJsonHelper.extractLongNamed(required, element)).notNull()
|
||||
.integerGreaterThanZero();
|
||||
}
|
||||
}
|
||||
|
||||
validateOptionalFields(element, v);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
@@ -150,6 +121,11 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
|
||||
.value(this.fromApiJsonHelper.extractStringNamed("autoInterestPostWhen", element)).ignoreIfNull()
|
||||
.notExceedingLengthOf(20);
|
||||
}
|
||||
if (this.fromApiJsonHelper.parameterExists("autoInterestPostDay", element)) {
|
||||
v.reset().parameter("autoInterestPostDay")
|
||||
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("autoInterestPostDay", element)).ignoreIfNull()
|
||||
.inMinMaxRange(1, 31);
|
||||
}
|
||||
|
||||
// Premature closure terms. Zero is allowed throughout - a scheme may close early from day
|
||||
// one, or charge the customer or the agent nothing - so these are floored at zero, not at one.
|
||||
|
||||
+4
-32
@@ -43,29 +43,15 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
|
||||
public String schema() {
|
||||
return " t.id as id, t.account_type_group as accountTypeGroup, t.account_type_code as accountTypeCode, "
|
||||
+ " t.account_type_description as accountTypeDescription, t.auto_interest_post as autoInterestPost, "
|
||||
+ " t.auto_interest_post_when as autoInterestPostWhen, "
|
||||
+ " t.auto_interest_post_when as autoInterestPostWhen, t.auto_interest_post_day as autoInterestPostDay, "
|
||||
+ " t.maturity_auto_close as maturityAutoClose, "
|
||||
+ " t.premature_lockin_months as prematureLockinMonths, "
|
||||
+ " t.premature_interest_reduction as prematureInterestReduction, "
|
||||
+ " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, "
|
||||
+ " t.interest_rate as interestRate, "
|
||||
+ " t.is_active as active, "
|
||||
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate, "
|
||||
+ " t.deposit_liability_gl_id as depositLiabilityGlId, dl.gl_code as depositLiabilityGlCode, "
|
||||
+ " dl.name as depositLiabilityGlName, "
|
||||
+ " t.interest_expense_gl_id as interestExpenseGlId, ie.gl_code as interestExpenseGlCode, "
|
||||
+ " ie.name as interestExpenseGlName, "
|
||||
+ " t.commission_expense_gl_id as commissionExpenseGlId, ce.gl_code as commissionExpenseGlCode, "
|
||||
+ " ce.name as commissionExpenseGlName, "
|
||||
+ " t.premature_income_gl_id as prematureIncomeGlId, pi.gl_code as prematureIncomeGlCode, "
|
||||
+ " pi.name as prematureIncomeGlName, "
|
||||
+ " t.tds_payable_gl_id as tdsPayableGlId, tp.gl_code as tdsPayableGlCode, tp.name as tdsPayableGlName "
|
||||
+ " from m_dds_account_type t "
|
||||
+ " left join acc_gl_account dl on dl.id = t.deposit_liability_gl_id "
|
||||
+ " left join acc_gl_account ie on ie.id = t.interest_expense_gl_id "
|
||||
+ " left join acc_gl_account ce on ce.id = t.commission_expense_gl_id "
|
||||
+ " left join acc_gl_account pi on pi.id = t.premature_income_gl_id "
|
||||
+ " left join acc_gl_account tp on tp.id = t.tds_payable_gl_id";
|
||||
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate "
|
||||
+ " from m_dds_account_type t";
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -77,27 +63,13 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
|
||||
.accountTypeDescription(rs.getString("accountTypeDescription")) //
|
||||
.autoInterestPost(rs.getBoolean("autoInterestPost")) //
|
||||
.autoInterestPostWhen(rs.getString("autoInterestPostWhen")) //
|
||||
.autoInterestPostDay(JdbcSupport.getInteger(rs, "autoInterestPostDay")) //
|
||||
.maturityAutoClose(rs.getBoolean("maturityAutoClose")) //
|
||||
.prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) //
|
||||
.prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) //
|
||||
.prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) //
|
||||
.interestRate(rs.getBigDecimal("interestRate")) //
|
||||
.active(rs.getBoolean("active")) //
|
||||
.depositLiabilityGlId(JdbcSupport.getLong(rs, "depositLiabilityGlId")) //
|
||||
.depositLiabilityGlCode(rs.getString("depositLiabilityGlCode")) //
|
||||
.depositLiabilityGlName(rs.getString("depositLiabilityGlName")) //
|
||||
.interestExpenseGlId(JdbcSupport.getLong(rs, "interestExpenseGlId")) //
|
||||
.interestExpenseGlCode(rs.getString("interestExpenseGlCode")) //
|
||||
.interestExpenseGlName(rs.getString("interestExpenseGlName")) //
|
||||
.commissionExpenseGlId(JdbcSupport.getLong(rs, "commissionExpenseGlId")) //
|
||||
.commissionExpenseGlCode(rs.getString("commissionExpenseGlCode")) //
|
||||
.commissionExpenseGlName(rs.getString("commissionExpenseGlName")) //
|
||||
.prematureIncomeGlId(JdbcSupport.getLong(rs, "prematureIncomeGlId")) //
|
||||
.prematureIncomeGlCode(rs.getString("prematureIncomeGlCode")) //
|
||||
.prematureIncomeGlName(rs.getString("prematureIncomeGlName")) //
|
||||
.tdsPayableGlId(JdbcSupport.getLong(rs, "tdsPayableGlId")) //
|
||||
.tdsPayableGlCode(rs.getString("tdsPayableGlCode")) //
|
||||
.tdsPayableGlName(rs.getString("tdsPayableGlName")) //
|
||||
.createdDate(localDateTime(rs, "createdDate")) //
|
||||
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
|
||||
.build();
|
||||
|
||||
-68
@@ -19,7 +19,6 @@
|
||||
package org.apache.fineract.portfolio.dds.accounttype.service;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -36,10 +35,7 @@ import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNot
|
||||
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -50,16 +46,6 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
|
||||
private final DdsAccountTypeRepository ddsAccountTypeRepository;
|
||||
private final PartyRepository partyRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
private static final String GL_SQL = "select classification_enum, account_usage, disabled, gl_code, name from acc_gl_account where id = ?";
|
||||
|
||||
/** acc_gl_account.classification_enum: 2 liability, 5 expense. */
|
||||
private static final int LIABILITY = 2;
|
||||
private static final int INCOME = 4;
|
||||
private static final int EXPENSE = 5;
|
||||
/** acc_gl_account.account_usage 1 - a detail account an entry can be posted to, not a header. */
|
||||
private static final int DETAIL = 1;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -67,7 +53,6 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForCreate(command.json());
|
||||
validateLedgerAccounts(command);
|
||||
|
||||
final DdsAccountType accountType = DdsAccountType.fromJson(command);
|
||||
this.ddsAccountTypeRepository.saveAndFlush(accountType);
|
||||
@@ -92,7 +77,6 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
|
||||
try {
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
validateLedgerAccounts(command);
|
||||
|
||||
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
|
||||
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
|
||||
@@ -116,13 +100,6 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
changesOnly.put("accountsRepriced", openAccounts.size());
|
||||
}
|
||||
|
||||
// A renamed product: the accounts on it - tied to it by id - show the new code.
|
||||
if (changesOnly.containsKey("accountTypeCode")) {
|
||||
final int moved = this.jdbcTemplate.update("update m_dds_party set sub_type = ? where account_type_id = ?", // NOSONAR
|
||||
accountType.getAccountTypeCode(), accountTypeId);
|
||||
changesOnly.put("accountsRenamed", moved);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(accountTypeId) //
|
||||
@@ -138,51 +115,6 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each ledger account a product names must be one an entry can land on, and of the kind its job
|
||||
* needs - the same rule the savings product's Accounting tab applies by offering only those. The
|
||||
* customers' money is owed, so a liability; their interest and the agents' commission are costs, so
|
||||
* expenses.
|
||||
*/
|
||||
private void validateLedgerAccounts(final JsonCommand command) {
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
checkLedgerAccount(command, "depositLiabilityGlId", "Customer Deposits", List.of(LIABILITY), errors);
|
||||
checkLedgerAccount(command, "interestExpenseGlId", "Interest Expense", List.of(EXPENSE), errors);
|
||||
checkLedgerAccount(command, "commissionExpenseGlId", "Agent Commission Expense", List.of(EXPENSE), errors);
|
||||
// What an early closure takes back - the customer's interest and the agent's penalty - the bank earns.
|
||||
checkLedgerAccount(command, "prematureIncomeGlId", "Premature Closure Income", List.of(INCOME), errors);
|
||||
// TDS withheld from commission is owed to the tax department until it is paid over.
|
||||
checkLedgerAccount(command, "tdsPayableGlId", "TDS Payable", List.of(LIABILITY), errors);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkLedgerAccount(final JsonCommand command, final String parameter, final String label, final List<Integer> kinds,
|
||||
final List<ApiParameterError> errors) {
|
||||
final Long glId = command.parameterExists(parameter) ? command.longValueOfParameterNamed(parameter) : null;
|
||||
if (glId == null) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(GL_SQL, glId); // NOSONAR
|
||||
final String code = "error.msg.dds.account.type." + parameter + ".invalid";
|
||||
if (rows.isEmpty()) {
|
||||
errors.add(ApiParameterError.parameterError(code, label + ": GL account " + glId + " does not exist.", parameter));
|
||||
return;
|
||||
}
|
||||
final Map<String, Object> gl = rows.get(0);
|
||||
final String name = gl.get("name") + " (" + gl.get("gl_code") + ")";
|
||||
if (Boolean.TRUE.equals(gl.get("disabled"))) {
|
||||
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is disabled.", parameter));
|
||||
} else if (((Number) gl.get("account_usage")).intValue() != DETAIL) {
|
||||
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is a header account and cannot take entries.",
|
||||
parameter));
|
||||
} else if (!kinds.contains(((Number) gl.get("classification_enum")).intValue())) {
|
||||
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is not "
|
||||
+ (kinds.get(0) == LIABILITY ? "a liability" : "an expense") + " account.", parameter));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
|
||||
if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) {
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code",
|
||||
|
||||
+2
-3
@@ -43,8 +43,7 @@ public class DdsAccountTypeConfiguration {
|
||||
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
|
||||
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
|
||||
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository,
|
||||
PartyRepository partyRepository, JdbcTemplate jdbcTemplate) {
|
||||
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository,
|
||||
jdbcTemplate);
|
||||
PartyRepository partyRepository) {
|
||||
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-712
@@ -22,23 +22,13 @@ import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException;
|
||||
import org.apache.fineract.commands.exception.CommandNotFoundException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
|
||||
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.calculation.DdsInterestCalculation;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -73,9 +63,6 @@ public class DdsApprovalReadService {
|
||||
private static final String REF_KEY_PREFIX = "DDS_COMMAND:";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final DdsLedger ddsLedger;
|
||||
private final PartyRepository partyRepository;
|
||||
private final BatchRunWritePlatformService batchRunWritePlatformService;
|
||||
|
||||
public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) {
|
||||
final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? ");
|
||||
@@ -108,568 +95,9 @@ public class DdsApprovalReadService {
|
||||
final Map<String, Object> row = describe(found.get(0));
|
||||
row.put("description", text(jsonOf(found.get(0).get("json")), "withdrawDescription"));
|
||||
row.put("lines", linesFor(row, found.get(0)));
|
||||
row.put("fields", fieldsFor(row, found.get(0)));
|
||||
if (isAgentRequest(row)) {
|
||||
row.put("guarantors", guarantorsOf(jsonOf(found.get(0).get("json"))));
|
||||
}
|
||||
if ("COMMISSIONSLAB".equals(row.get("entity"))) {
|
||||
row.put("slabChart", slabChart(row, jsonOf(found.get(0).get("json"))));
|
||||
}
|
||||
if (isPartyRequest(row)) {
|
||||
final JsonObject partyJson = jsonOf(found.get(0).get("json"));
|
||||
row.put("nominees", nomineesOf(partyJson));
|
||||
row.put("nomineeSplit", text(partyJson, "nomineeSplitMode"));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** A DDS account (party) being opened or changed - the entries whose review shows the account and its nominees. */
|
||||
private static boolean isPartyRequest(final Map<String, Object> row) {
|
||||
return "PARTY".equals(row.get("entity")) && ("CREATE".equals(row.get("action")) || "UPDATE".equals(row.get("action")));
|
||||
}
|
||||
|
||||
/** What a DDS account request carries, in the order the Party Master Entry screen asks for it. */
|
||||
private record PartyField(String parameter, String label, String group, String kind) {}
|
||||
|
||||
private static final String PARTY_GROUP_CUSTOMER = "Customer & Scheme";
|
||||
private static final String PARTY_GROUP_PERSONAL = "Personal & Contact";
|
||||
private static final String PARTY_GROUP_MINOR = "Minor / Guardian";
|
||||
private static final String PARTY_GROUP_TERMS = "Scheme Terms";
|
||||
private static final String PARTY_GROUP_STATUS = "Balances & Status";
|
||||
private static final String PARTY_GROUP_PAYOUT = "Maturity Payout";
|
||||
|
||||
private static final List<PartyField> PARTY_FIELDS = List.of( //
|
||||
new PartyField("clientId", "Client", PARTY_GROUP_CUSTOMER, "client"), //
|
||||
new PartyField("subType", "Scheme", PARTY_GROUP_CUSTOMER, "scheme"), //
|
||||
new PartyField("agentId", "Agent", PARTY_GROUP_CUSTOMER, "agent"), //
|
||||
new PartyField("officeId", "Branch", PARTY_GROUP_CUSTOMER, "office"), //
|
||||
new PartyField("nominal", "Nominal", PARTY_GROUP_CUSTOMER, "yesno"), //
|
||||
new PartyField("partyName", "Party Name", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("partyName1", "Party Name 1", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address1", "Address 1", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address2", "Address 2", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("address3", "Address 3", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("city", "City", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("pincode", "Pincode", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("gender", "Gender", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("telephone", "Telephone", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("mobileNoSms", "Mobile No (SMS)", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("aadharNo", "Aadhar No", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("panCardNo", "PAN Card No", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("ckycId", "CKYC ID", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("operation", "Operation", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("birthDate", "Birth Date", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("occupation", "Occupation", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("risk", "Risk", PARTY_GROUP_PERSONAL, "text"), //
|
||||
new PartyField("adultMinor", "Adult / Minor", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("guardianClientId", "Guardian", PARTY_GROUP_MINOR, "client"), //
|
||||
new PartyField("relation", "Relation", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("minorDate", "Comes of Age On", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("minAccountOpt", "Minor Account Option", PARTY_GROUP_MINOR, "text"), //
|
||||
new PartyField("openingDate", "Opening Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("dailyDepositAmount", "Daily Deposit", PARTY_GROUP_TERMS, "money"), //
|
||||
new PartyField("days", "Days", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("month", "Months", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("depositAmount", "Deposit Amount", PARTY_GROUP_TERMS, "money"), //
|
||||
new PartyField("maturityDate", "Maturity Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("ledgerNo", "Ledger No", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("reopenDate", "Reopen Date", PARTY_GROUP_TERMS, "text"), //
|
||||
new PartyField("freezeCode", "Freeze Code", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("lien", "Lien", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("lienReason", "Lien Reason", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("lienReasonOther", "Lien Reason (Other)", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("isSmsActive", "SMS Active", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("isEmailActive", "Email Active", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("od1Flag", "OD 1", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("od2Flag", "OD 2", PARTY_GROUP_STATUS, "yesno"), //
|
||||
new PartyField("payDate", "Pay Date", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("interestDate", "Interest Date", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("spDescription", "Description", PARTY_GROUP_STATUS, "text"), //
|
||||
new PartyField("advCode", "Adv Code", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToBranch", "Credit To Branch", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToAccount", "Credit To A/C", PARTY_GROUP_PAYOUT, "text"), //
|
||||
new PartyField("creditToAccountType", "Credit To A/C Type", PARTY_GROUP_PAYOUT, "text"));
|
||||
|
||||
/**
|
||||
* A DDS account request laid out for the checker: the client, scheme, agent and branch by name
|
||||
* rather than by number, in the sections the screen has. Only what the request carries is shown, so
|
||||
* an edit lists just the fields it changes, and a field left empty is left out.
|
||||
*/
|
||||
private List<Map<String, Object>> partyFieldsFor(final JsonObject json) {
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final PartyField field : PARTY_FIELDS) {
|
||||
if (!json.has(field.parameter()) || json.get(field.parameter()).isJsonNull()) {
|
||||
continue;
|
||||
}
|
||||
final String raw = json.get(field.parameter()).getAsString();
|
||||
if (raw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
final String value = switch (field.kind()) {
|
||||
case "client" -> clientLabel(raw);
|
||||
case "scheme" -> nameOr(first("select account_type_code || ' · ' || account_type_description from m_dds_account_type "
|
||||
+ "where account_type_code = ?", raw), raw);
|
||||
case "agent" -> nameOr(first("select agent_code || ' · ' || coalesce(agent_name, '') from m_dds_agent where id = ?",
|
||||
parseLong(raw)), "Agent #" + raw);
|
||||
case "office" -> nameOr(first("select name from m_office where id = ?", parseLong(raw)), "Branch #" + raw);
|
||||
case "yesno" -> Boolean.parseBoolean(raw.trim()) ? "Yes" : "No";
|
||||
case "money" -> "₹" + plain(raw);
|
||||
default -> raw.trim();
|
||||
};
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", "yesno".equals(field.kind()) ? "yesno" : "text");
|
||||
out.put("value", value);
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** The nominees a DDS account request names, with what each is to receive. */
|
||||
private List<Map<String, Object>> nomineesOf(final JsonObject json) {
|
||||
final List<Map<String, Object>> nominees = new ArrayList<>();
|
||||
if (!json.has("nominees") || !json.get("nominees").isJsonArray()) {
|
||||
return nominees;
|
||||
}
|
||||
for (final JsonElement element : json.getAsJsonArray("nominees")) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject n = element.getAsJsonObject();
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("name", text(n, "name"));
|
||||
out.put("relation", text(n, "relation"));
|
||||
out.put("gender", text(n, "gender"));
|
||||
out.put("mobileNo", text(n, "mobileNo"));
|
||||
out.put("birthDate", text(n, "birthDate"));
|
||||
out.put("nominationDate", text(n, "nominationDate"));
|
||||
out.put("guardianName", text(n, "guardianName"));
|
||||
out.put("share", text(n, "sharePercent") != null ? plain(text(n, "sharePercent")) + "%"
|
||||
: text(n, "sequenceNo") != null ? "Order " + text(n, "sequenceNo") : null);
|
||||
nominees.add(out);
|
||||
}
|
||||
return nominees;
|
||||
}
|
||||
|
||||
/** An agent being opened or changed - the entries whose review shows the agent and their guarantors. */
|
||||
private static boolean isAgentRequest(final Map<String, Object> row) {
|
||||
return "AGENT".equals(row.get("entity")) && ("CREATE".equals(row.get("action")) || "UPDATE".equals(row.get("action")));
|
||||
}
|
||||
|
||||
/** What an agent request carries, in the order the Agent Master Entry screen asks for it. */
|
||||
private record AgentField(String parameter, String label, String group, String kind) {}
|
||||
|
||||
private static final String AGENT_GROUP = "Agent";
|
||||
private static final String AGENT_ADDRESS_GROUP = "Address & Contact";
|
||||
private static final String AGENT_ACCOUNTS_GROUP = "Branch & Accounts";
|
||||
private static final String AGENT_BALANCES_GROUP = "Balances";
|
||||
|
||||
private static final List<AgentField> AGENT_FIELDS = List.of( //
|
||||
new AgentField("clientId", "Client", AGENT_GROUP, "client"), //
|
||||
new AgentField("agentNameId", "Agent Name", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentBirthDate", "Birth Date", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentOpeningDate", "Opening Date", AGENT_GROUP, "text"), //
|
||||
new AgentField("agentAddress1", "Address (Line 1)", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentAddress2", "Address (Line 2)", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentCity", "City", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentPinCode", "PinCode", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("agentTelephone", "Telephone", AGENT_ADDRESS_GROUP, "text"), //
|
||||
new AgentField("officeId", "Home Branch", AGENT_ACCOUNTS_GROUP, "office"), //
|
||||
new AgentField("glAccountId", "GL Account", AGENT_ACCOUNTS_GROUP, "gl"), //
|
||||
new AgentField("agentSavingAccountType", "Saving A/C Type", AGENT_ACCOUNTS_GROUP, "text"), //
|
||||
new AgentField("agentSavingAccountNo", "Saving A/C No", AGENT_ACCOUNTS_GROUP, "text"), //
|
||||
new AgentField("agentOpeningBalance", "Opening Balance", AGENT_BALANCES_GROUP, "money"));
|
||||
|
||||
/**
|
||||
* An agent request laid out for the checker: the client, the branch and the ledger account by name
|
||||
* rather than by number, in the sections the screen has. Only what the request carries is shown, so
|
||||
* an edit lists just the fields it changes.
|
||||
*/
|
||||
private List<Map<String, Object>> agentFieldsFor(final JsonObject json) {
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final AgentField field : AGENT_FIELDS) {
|
||||
if (!json.has(field.parameter()) || json.get(field.parameter()).isJsonNull()) {
|
||||
continue;
|
||||
}
|
||||
final String raw = json.get(field.parameter()).getAsString();
|
||||
// A new agent starts with nothing, so a zero opening balance is not something to approve and
|
||||
// is left out. One that is not zero - an edit that sets it - is shown.
|
||||
if ("money".equals(field.kind()) && isZero(raw)) {
|
||||
continue;
|
||||
}
|
||||
final String value = switch (field.kind()) {
|
||||
case "client" -> clientLabel(raw);
|
||||
case "office" -> nameOr(first("select name from m_office where id = ?", parseLong(raw)), "Branch #" + raw);
|
||||
case "gl" -> nameOr(first("select gl_code || ' · ' || name from acc_gl_account where id = ?", parseLong(raw)),
|
||||
"Account #" + raw);
|
||||
case "money" -> "₹" + plain(raw);
|
||||
default -> raw.trim();
|
||||
};
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", "text");
|
||||
out.put("value", value);
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** The guarantors an agent request names, each with the client they are by number and name. */
|
||||
private List<Map<String, Object>> guarantorsOf(final JsonObject json) {
|
||||
final List<Map<String, Object>> guarantors = new ArrayList<>();
|
||||
if (!json.has("guarantors") || !json.get("guarantors").isJsonArray()) {
|
||||
return guarantors;
|
||||
}
|
||||
for (final JsonElement element : json.getAsJsonArray("guarantors")) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject g = element.getAsJsonObject();
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("client", text(g, "clientId") == null ? null : clientLabel(text(g, "clientId")));
|
||||
out.put("name", text(g, "guarantorName"));
|
||||
out.put("telephone", text(g, "telephone"));
|
||||
out.put("address", joined(", ", text(g, "address1"), text(g, "address2"), text(g, "city"), text(g, "pinCode")));
|
||||
guarantors.add(out);
|
||||
}
|
||||
return guarantors;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's code, name, client, branch, savings account and guarantor count. An agent already on
|
||||
* the books is read as they stand; one still being opened does not exist yet, so is read from the
|
||||
* request and has no code until approval gives it one.
|
||||
*/
|
||||
private Map<String, Object> agentColumns(final Long agentId, final JsonObject json) {
|
||||
final Map<String, Object> columns = new LinkedHashMap<>();
|
||||
if (agentId != null) {
|
||||
columns.putAll(this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select a.agent_code as \"agentCode\", a.agent_name as \"agentName\", c.display_name as \"clientName\", "
|
||||
+ "o.name as \"officeName\", a.agent_saving_account_no as \"savingAccountNo\", "
|
||||
+ "(select count(*) from m_dds_agent_guarantor g where g.agent_id = a.id) as \"guarantorCount\" "
|
||||
+ "from m_dds_agent a left join m_client c on c.id = a.client_id left join m_office o on o.id = a.office_id "
|
||||
+ "where a.id = ?",
|
||||
agentId).stream().findFirst().orElse(Map.of()));
|
||||
return columns;
|
||||
}
|
||||
final String clientId = text(json, "clientId");
|
||||
columns.put("agentCode", null);
|
||||
columns.put("agentName", text(json, "agentNameId"));
|
||||
columns.put("clientName", clientId == null ? null : first("select display_name from m_client where id = ?", parseLong(clientId)));
|
||||
final String officeId = text(json, "officeId");
|
||||
columns.put("officeName", officeId == null ? null : first("select name from m_office where id = ?", parseLong(officeId)));
|
||||
columns.put("savingAccountNo", text(json, "agentSavingAccountNo"));
|
||||
columns.put("guarantorCount", json.has("guarantors") && json.get("guarantors").isJsonArray() ? json.getAsJsonArray("guarantors").size() : 0);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private String clientLabel(final String clientId) {
|
||||
final String name = first("select display_name from m_client where id = ?", parseLong(clientId));
|
||||
return name == null ? "Client #" + clientId : clientId + " · " + name;
|
||||
}
|
||||
|
||||
/** The name an agent request gives, or the client it is for when none was typed. */
|
||||
private String agentNameOf(final JsonObject json) {
|
||||
final String name = text(json, "agentNameId");
|
||||
if (name != null && !name.isBlank()) {
|
||||
return name.trim();
|
||||
}
|
||||
final String clientId = text(json, "clientId");
|
||||
return clientId == null ? "" : clientLabel(clientId);
|
||||
}
|
||||
|
||||
private static boolean isZero(final String amount) {
|
||||
try {
|
||||
return new BigDecimal(amount.trim()).signum() == 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String nameOr(final String found, final String fallback) {
|
||||
return found == null ? fallback : found;
|
||||
}
|
||||
|
||||
private static String joined(final String separator, final String... parts) {
|
||||
final List<String> present = new ArrayList<>();
|
||||
for (final String part : parts) {
|
||||
if (part != null && !part.isBlank()) {
|
||||
present.add(part.trim());
|
||||
}
|
||||
}
|
||||
return String.join(separator, present);
|
||||
}
|
||||
|
||||
/** A number an agent request carried as text, or -1 - which no row has - when it was not one. */
|
||||
private static Long parseLong(final String value) {
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS product's terms, in the order the Account Type screen asks for them: the column it is
|
||||
* stored in, the parameter the request carries it as, its label, how it reads, and the section of
|
||||
* the review it is shown in.
|
||||
*/
|
||||
private record ProductField(String column, String parameter, String label, String format, String group) {}
|
||||
|
||||
private static final String GROUP_PRODUCT = "Product";
|
||||
private static final String GROUP_INTEREST = "Interest";
|
||||
private static final String GROUP_CLOSURE = "Maturity & Premature Closure";
|
||||
private static final String GROUP_ACCOUNTING = "Accounting";
|
||||
|
||||
private static final List<ProductField> PRODUCT_FIELDS = List.of( //
|
||||
new ProductField("account_type_group", "accountTypeGroup", "Account Type Group", "text", GROUP_PRODUCT), //
|
||||
new ProductField("account_type_code", "accountTypeCode", "Account Type Code", "text", GROUP_PRODUCT), //
|
||||
new ProductField("account_type_description", "accountTypeDescription", "Description", "text", GROUP_PRODUCT), //
|
||||
new ProductField("is_active", "active", "Active", "yesno", GROUP_PRODUCT), //
|
||||
new ProductField("interest_rate", "interestRate", "Interest Rate", "percent", GROUP_INTEREST), //
|
||||
new ProductField("auto_interest_post", "autoInterestPost", "Auto Interest Post", "yesno", GROUP_INTEREST), //
|
||||
new ProductField("auto_interest_post_when", "autoInterestPostWhen", "Auto Interest Post When", "text", GROUP_INTEREST), //
|
||||
new ProductField("maturity_auto_close", "maturityAutoClose", "Maturity Auto Close", "yesno", GROUP_CLOSURE), //
|
||||
new ProductField("premature_lockin_months", "prematureLockinMonths", "Premature Lock-in", "months", GROUP_CLOSURE), //
|
||||
new ProductField("premature_interest_reduction", "prematureInterestReduction", "Premature Interest Reduction", "percent",
|
||||
GROUP_CLOSURE), //
|
||||
new ProductField("premature_agent_commission_penalty", "prematureAgentCommissionPenalty", "Agent Commission Penalty",
|
||||
"percent", GROUP_CLOSURE), //
|
||||
new ProductField("deposit_liability_gl_id", "depositLiabilityGlId", "DDS Customer Deposits (Liability)", "gl",
|
||||
GROUP_ACCOUNTING), //
|
||||
new ProductField("interest_expense_gl_id", "interestExpenseGlId", "Interest Expense", "gl", GROUP_ACCOUNTING), //
|
||||
new ProductField("commission_expense_gl_id", "commissionExpenseGlId", "Agent Commission Expense", "gl", GROUP_ACCOUNTING), //
|
||||
new ProductField("premature_income_gl_id", "prematureIncomeGlId", "Premature Closure Income", "gl", GROUP_ACCOUNTING), //
|
||||
new ProductField("tds_payable_gl_id", "tdsPayableGlId", "TDS Payable (194H)", "gl", GROUP_ACCOUNTING));
|
||||
|
||||
private static final String GROUP_SLAB = "Commission Slab";
|
||||
|
||||
/** A commission slab's terms, in the order the Commission Slab Master asks for them. */
|
||||
private static final List<ProductField> SLAB_FIELDS = List.of( //
|
||||
new ProductField("slab_type", "slabType", "Slab Type", "slabtype", GROUP_SLAB), //
|
||||
new ProductField("slab_from", "slabFrom", "Collection From", "money", GROUP_SLAB), //
|
||||
new ProductField("slab_to", "slabTo", "Collection To", "money", GROUP_SLAB), //
|
||||
new ProductField("commission_percent", "commissionPercent", "Commission", "percent", GROUP_SLAB));
|
||||
|
||||
/** The slab a request names - what it asks for, and what it leaves out as the slab stands. */
|
||||
private Map<String, Object> slabColumns(final JsonObject json, final Long resourceId) {
|
||||
final Map<String, Object> stored = resourceId == null ? Map.of()
|
||||
: this.jdbcTemplate.queryForList("select * from m_dds_commission_slab where id = ?", resourceId).stream() // NOSONAR
|
||||
.findFirst().orElse(Map.of());
|
||||
final Map<String, Object> columns = new LinkedHashMap<>();
|
||||
columns.put("slabType", text(json, "slabType") != null ? text(json, "slabType") : stored.get("slab_type"));
|
||||
columns.put("slabFrom", number(json, "slabFrom") != null ? number(json, "slabFrom") : stored.get("slab_from"));
|
||||
columns.put("slabTo", number(json, "slabTo") != null ? number(json, "slabTo") : stored.get("slab_to"));
|
||||
columns.put("commissionPercent",
|
||||
number(json, "commissionPercent") != null ? number(json, "commissionPercent") : stored.get("commission_percent"));
|
||||
return columns;
|
||||
}
|
||||
|
||||
/** "Account · ₹50,001 – ₹1,00,000 · 2%" - what the slab is, from the request or, failing that, the slab itself. */
|
||||
private String slabSummary(final JsonObject json, final Long resourceId) {
|
||||
final Map<String, Object> slab = slabColumns(json, resourceId);
|
||||
final String type = (String) slab.get("slabType");
|
||||
final BigDecimal from = (BigDecimal) slab.get("slabFrom");
|
||||
final BigDecimal to = (BigDecimal) slab.get("slabTo");
|
||||
final BigDecimal rate = (BigDecimal) slab.get("commissionPercent");
|
||||
if (from == null && to == null && rate == null) {
|
||||
return resourceId == null ? "" : "Slab #" + resourceId;
|
||||
}
|
||||
return format(type, "slabtype") + " · " + format(from == null ? null : from.toPlainString(), "money") + " – "
|
||||
+ format(to == null ? null : to.toPlainString(), "money") + " · "
|
||||
+ format(rate == null ? null : rate.toPlainString(), "percent");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every slab of the request's type, as the chart will read once it is approved: the slab asked for
|
||||
* marked, a slab it overlaps flagged, so the checker sees whether the chart still has one rate for
|
||||
* every collection volume.
|
||||
*/
|
||||
private List<Map<String, Object>> slabChart(final Map<String, Object> row, final JsonObject json) {
|
||||
final Long resourceId = (Long) row.get("resourceId");
|
||||
final boolean pending = "PENDING".equals(row.get("status"));
|
||||
final Map<String, Object> stored = resourceId == null ? Map.of()
|
||||
: this.jdbcTemplate.queryForList("select * from m_dds_commission_slab where id = ?", resourceId).stream() // NOSONAR
|
||||
.findFirst().orElse(Map.of());
|
||||
final String type = text(json, "slabType") != null ? text(json, "slabType") : (String) stored.get("slab_type");
|
||||
if (type == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final List<Map<String, Object>> chart = new ArrayList<>();
|
||||
BigDecimal from = null;
|
||||
BigDecimal to = null;
|
||||
for (final Map<String, Object> each : this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select id, slab_from, slab_to, commission_percent from m_dds_commission_slab where slab_type = ? order by slab_from, id",
|
||||
type)) {
|
||||
final Long id = ((Number) each.get("id")).longValue();
|
||||
final Map<String, Object> line = new LinkedHashMap<>();
|
||||
line.put("id", id);
|
||||
line.put("from", each.get("slab_from"));
|
||||
line.put("to", each.get("slab_to"));
|
||||
line.put("rate", each.get("commission_percent"));
|
||||
if (id.equals(resourceId)) {
|
||||
line.put("requested", true);
|
||||
if (pending) {
|
||||
// As it will stand once approved, with what it was beside it.
|
||||
line.put("was", Map.of("from", each.get("slab_from"), "to", each.get("slab_to"), "rate", each.get("commission_percent")));
|
||||
putIfGiven(line, "from", number(json, "slabFrom"));
|
||||
putIfGiven(line, "to", number(json, "slabTo"));
|
||||
putIfGiven(line, "rate", number(json, "commissionPercent"));
|
||||
}
|
||||
from = (BigDecimal) line.get("from");
|
||||
to = (BigDecimal) line.get("to");
|
||||
}
|
||||
chart.add(line);
|
||||
}
|
||||
if (resourceId == null || stored.isEmpty()) {
|
||||
// A new slab still waiting: not in the table yet, so placed where it will sit.
|
||||
final Map<String, Object> line = new LinkedHashMap<>();
|
||||
line.put("id", null);
|
||||
line.put("from", number(json, "slabFrom"));
|
||||
line.put("to", number(json, "slabTo"));
|
||||
line.put("rate", number(json, "commissionPercent"));
|
||||
line.put("requested", true);
|
||||
from = (BigDecimal) line.get("from");
|
||||
to = (BigDecimal) line.get("to");
|
||||
chart.add(line);
|
||||
chart.sort((a, b) -> a.get("from") == null || b.get("from") == null ? 0
|
||||
: ((BigDecimal) a.get("from")).compareTo((BigDecimal) b.get("from")));
|
||||
}
|
||||
if (from != null && to != null) {
|
||||
for (final Map<String, Object> line : chart) {
|
||||
if (Boolean.TRUE.equals(line.get("requested")) || line.get("from") == null || line.get("to") == null) {
|
||||
continue;
|
||||
}
|
||||
final boolean overlaps = ((BigDecimal) line.get("from")).compareTo(to) <= 0 && ((BigDecimal) line.get("to")).compareTo(from) >= 0;
|
||||
line.put("overlaps", overlaps);
|
||||
}
|
||||
}
|
||||
return chart;
|
||||
}
|
||||
|
||||
private static void putIfGiven(final Map<String, Object> line, final String key, final BigDecimal value) {
|
||||
if (value != null) {
|
||||
line.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What an entry that moves no money actually asks for, field by field, so the checker can read the
|
||||
* terms they are approving rather than only a code.
|
||||
*
|
||||
* An edit still waiting is shown against the product as it stands, with each changed field marked,
|
||||
* because what matters there is what will change. Once actioned, the product has already moved on,
|
||||
* so only the values the request carried are shown.
|
||||
*/
|
||||
private List<Map<String, Object>> fieldsFor(final Map<String, Object> row, final Map<String, Object> entry) {
|
||||
if (isAgentRequest(row)) {
|
||||
return agentFieldsFor(jsonOf(entry.get("json")));
|
||||
}
|
||||
if (isPartyRequest(row)) {
|
||||
return partyFieldsFor(jsonOf(entry.get("json")));
|
||||
}
|
||||
final boolean slab = "COMMISSIONSLAB".equals(row.get("entity"));
|
||||
if (!slab && !"DDSACCOUNTTYPE".equals(row.get("entity"))) {
|
||||
return List.of();
|
||||
}
|
||||
final String table = slab ? "m_dds_commission_slab" : "m_dds_account_type";
|
||||
final JsonObject json = jsonOf(entry.get("json"));
|
||||
final Long resourceId = (Long) row.get("resourceId");
|
||||
final boolean compare = "UPDATE".equals(row.get("action")) && "PENDING".equals(row.get("status")) && resourceId != null;
|
||||
final Map<String, Object> current = compare
|
||||
? this.jdbcTemplate.queryForList("select * from " + table + " where id = ?", resourceId).stream().findFirst() // NOSONAR
|
||||
.orElse(Map.of())
|
||||
: Map.of();
|
||||
|
||||
final List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for (final ProductField field : slab ? SLAB_FIELDS : PRODUCT_FIELDS) {
|
||||
final boolean requested = json.has(field.parameter()) && !json.get(field.parameter()).isJsonNull();
|
||||
final String proposed = requested ? display(json.get(field.parameter()).getAsString(), field.format()) : null;
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("key", field.parameter());
|
||||
out.put("label", field.label());
|
||||
out.put("group", field.group());
|
||||
out.put("format", field.format());
|
||||
if (compare) {
|
||||
final Object stored = current.get(field.column());
|
||||
final String was = stored == null ? null : display(String.valueOf(stored), field.format());
|
||||
final String now = requested ? proposed : was;
|
||||
out.put("current", was);
|
||||
out.put("value", now);
|
||||
out.put("changed", requested && !Objects.equals(was, now));
|
||||
} else if (requested) {
|
||||
out.put("value", proposed);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
fields.add(out);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** As format, and a ledger account id read as the account itself: "DDS CUSTOMER DEPOSITS (200601)". */
|
||||
private String display(final String value, final String format) {
|
||||
if (!"gl".equals(format) || value == null || value.isBlank()) {
|
||||
return format(value, format);
|
||||
}
|
||||
final Map<String, Object> gl = this.jdbcTemplate.queryForList("select gl_code, name from acc_gl_account where id = ?", // NOSONAR
|
||||
parseLong(value)).stream().findFirst().orElse(Map.of());
|
||||
return gl.isEmpty() ? "GL " + value.trim() + " (not found)" : gl.get("name") + " (" + gl.get("gl_code") + ")";
|
||||
}
|
||||
|
||||
/** A stored or requested value as the screen shows it: Yes/No, 2%, 12 months. */
|
||||
private static String format(final String value, final String format) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return switch (format) {
|
||||
case "yesno" -> Boolean.parseBoolean(value.trim()) ? "Yes" : "No";
|
||||
case "percent" -> plain(value) + "%";
|
||||
case "months" -> plain(value) + " month" + ("1".equals(plain(value)) ? "" : "s");
|
||||
case "money" -> rupees(value);
|
||||
case "slabtype" -> value.trim().isEmpty() ? null
|
||||
: value.trim().substring(0, 1).toUpperCase() + value.trim().substring(1).toLowerCase();
|
||||
default -> value.trim();
|
||||
};
|
||||
}
|
||||
|
||||
/** ₹ with Indian grouping, paise only when there are any: 1000000.01 reads ₹10,00,000.01. */
|
||||
private static String rupees(final String value) {
|
||||
final String plain = plain(value);
|
||||
final boolean negative = plain.startsWith("-");
|
||||
final String digits = negative ? plain.substring(1) : plain;
|
||||
final int dot = digits.indexOf('.');
|
||||
final String whole = dot < 0 ? digits : digits.substring(0, dot);
|
||||
final String paise = dot < 0 ? "" : "." + (digits.substring(dot + 1) + "0").substring(0, 2);
|
||||
if (!whole.chars().allMatch(Character::isDigit)) {
|
||||
return value.trim();
|
||||
}
|
||||
final StringBuilder grouped = new StringBuilder();
|
||||
final int len = whole.length();
|
||||
for (int i = 0; i < len; i++) {
|
||||
grouped.append(whole.charAt(i));
|
||||
final int left = len - i - 1;
|
||||
if (left > 0 && (left == 3 || (left > 3 && (left - 3) % 2 == 0))) {
|
||||
grouped.append(',');
|
||||
}
|
||||
}
|
||||
return (negative ? "-₹" : "₹") + grouped + paise;
|
||||
}
|
||||
|
||||
/** 2.000000 reads as 2, and 1.50 as 1.5, so a stored rate and a typed one compare equal. */
|
||||
private static String plain(final String value) {
|
||||
try {
|
||||
return new BigDecimal(value.trim()).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException e) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The debit and credit sides of the entry, as its voucher shows them. Once the money has moved they
|
||||
* are the voucher's own lines; while it waits they are what approving it will move, worked out from
|
||||
@@ -732,49 +160,13 @@ public class DdsApprovalReadService {
|
||||
}
|
||||
}
|
||||
case "DDSBATCH:POST" -> {
|
||||
// The expense side, on the accounts of the DDS products concerned - interest on each
|
||||
// customer's product's interest account, commission shared over the commission accounts
|
||||
// of the products each agent collected for, as the posting itself will book it.
|
||||
final String jobType = first("select job_type from m_dds_batch_run where id = ?", resourceId);
|
||||
// A calculation books nothing itself; its lines are what the matching Posting run will
|
||||
// book, shown so the checker sees which accounts and ledger accounts the figures belong to.
|
||||
final boolean projected = jobType != null && jobType.endsWith("_CALCULATION");
|
||||
if (jobType != null && jobType.startsWith("COMMISSION")) {
|
||||
final Map<String, BigDecimal> byAccount = new LinkedHashMap<>();
|
||||
final Map<String, String> names = new LinkedHashMap<>();
|
||||
for (final Map<String, Object> runRow : this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select agent_id, amount, to_date from m_dds_batch_run_row where batch_run_id = ? and amount > 0 order by id",
|
||||
resourceId)) {
|
||||
try {
|
||||
final long agentOfRow = ((Number) runRow.get("agent_id")).longValue();
|
||||
final BigDecimal amountOfRow = (BigDecimal) runRow.get("amount");
|
||||
(projected && runRow.get("to_date") != null
|
||||
? this.ddsLedger.projectedCommissionSplit(agentOfRow, amountOfRow,
|
||||
((java.sql.Date) runRow.get("to_date")).toLocalDate())
|
||||
: this.ddsLedger.commissionSplit(agentOfRow, amountOfRow, resourceId))
|
||||
.forEach((account, share) -> {
|
||||
byAccount.merge(account.getGlCode(), share, BigDecimal::add);
|
||||
names.put(account.getGlCode(), account.getName());
|
||||
});
|
||||
} catch (final DdsLedgerNotConfiguredException e) {
|
||||
byAccount.merge("", (BigDecimal) runRow.get("amount"), BigDecimal::add);
|
||||
names.put("", "Agent Commission Expense not set on the DDS product");
|
||||
}
|
||||
}
|
||||
byAccount.forEach((code, share) -> lines.add(line("DR", code.isEmpty() ? null : code, names.get(code), "GL", share)));
|
||||
} else {
|
||||
for (final Map<String, Object> gl : this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select g.gl_code as \"code\", coalesce(g.name, 'Interest Expense not set on DDS product ' || "
|
||||
+ "upper(trim(p.sub_type))) as \"name\", sum(r.amount) as \"amount\" from m_dds_batch_run_row r "
|
||||
+ "join m_dds_party p on p.id = r.party_id "
|
||||
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
|
||||
+ "left join acc_gl_account g on g.id = t.interest_expense_gl_id "
|
||||
+ "where r.batch_run_id = ? and r.amount > 0 group by g.gl_code, g.name, upper(trim(p.sub_type)) "
|
||||
+ "order by g.gl_code",
|
||||
resourceId)) {
|
||||
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", (BigDecimal) gl.get("amount")));
|
||||
}
|
||||
}
|
||||
final String expense = first("select c.name from m_dds_batch_run r join c_configuration c on c.name = "
|
||||
+ "case when r.job_type like 'COMMISSION%' then 'dds-agent-commission-expense-account' "
|
||||
+ "else 'dds-interest-expense-account' end where r.id = ?", resourceId);
|
||||
final Map<String, Object> gl = expense == null ? Map.of()
|
||||
: this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from c_configuration c " // NOSONAR
|
||||
+ "join acc_gl_account g on g.id = c.value where c.name = ?", expense).stream().findFirst().orElse(Map.of());
|
||||
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount));
|
||||
for (final Map<String, Object> credited : this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select coalesce(p.account_no, a.agent_saving_account_no) as \"accountNo\", "
|
||||
+ "coalesce(p.party_code || ' · ' || p.party_name, a.agent_code || ' · ' || a.agent_name) as \"accountName\", "
|
||||
@@ -785,10 +177,6 @@ public class DdsApprovalReadService {
|
||||
lines.add(line("CR", (String) credited.get("accountNo"), (String) credited.get("accountName"),
|
||||
(String) credited.get("type"), (BigDecimal) credited.get("amount")));
|
||||
}
|
||||
if (projected) {
|
||||
// Marked, so the review says these entries are made by the Posting run, not on approval.
|
||||
lines.forEach(each -> each.put("projected", true));
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
// A change to a party, agent or scheme moves no money and has no sides.
|
||||
@@ -905,7 +293,7 @@ public class DdsApprovalReadService {
|
||||
if (reason != null) {
|
||||
details = details + " · Reason: " + reason;
|
||||
}
|
||||
amount = status == STATUS_AWAITING ? prematurePayout(resourceId, json) : paidOut(resourceId, "PREMATURE_PAYOUT");
|
||||
amount = status == STATUS_AWAITING ? balanceOf(resourceId) : paidOut(resourceId, "PREMATURE_PAYOUT");
|
||||
}
|
||||
case "PARTY:CLOSE" -> {
|
||||
type = "Close DDS Account";
|
||||
@@ -926,21 +314,15 @@ public class DdsApprovalReadService {
|
||||
case "CLOSE" -> "Close Agent";
|
||||
default -> "Edit Agent";
|
||||
};
|
||||
details = resourceId != null ? agent(resourceId) : agentNameOf(json);
|
||||
details = resourceId != null ? agent(resourceId) : String.valueOf(text(json, "agentName"));
|
||||
}
|
||||
case "DDSACCOUNTTYPE:CREATE", "DDSACCOUNTTYPE:UPDATE" -> {
|
||||
type = "CREATE".equals(action) ? "New DDS Product" : "Edit DDS Product";
|
||||
details = text(json, "accountTypeCode") != null ? text(json, "accountTypeCode") : scheme(resourceId);
|
||||
final String description = text(json, "accountTypeDescription");
|
||||
if (description != null && !description.isBlank()) {
|
||||
details = details == null || details.isBlank() ? description : details + " · " + description;
|
||||
}
|
||||
}
|
||||
case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> {
|
||||
type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab";
|
||||
details = slabSummary(json, resourceId);
|
||||
// The slab in the columns Approvals -> Agent & Slab Approval lists slabs by.
|
||||
row.putAll(slabColumns(json, resourceId));
|
||||
details = resourceId == null ? "" : "Slab #" + resourceId;
|
||||
}
|
||||
case "DDSBATCH:POST" -> {
|
||||
final Map<String, Object> run = batchRun(resourceId);
|
||||
@@ -960,14 +342,6 @@ public class DdsApprovalReadService {
|
||||
row.put("type", type);
|
||||
row.put("details", details);
|
||||
row.put("amount", amount);
|
||||
if ("PARTY".equals(entity) && ("CREATE".equals(action) || "UPDATE".equals(action))) {
|
||||
// The account in the columns Approvals -> Pending Account lists every other account by.
|
||||
row.putAll(accountColumns(resourceId, json));
|
||||
}
|
||||
if ("AGENT".equals(entity)) {
|
||||
// The agent in the columns Approvals -> Agent Approval lists them by.
|
||||
row.putAll(agentColumns(resourceId, json));
|
||||
}
|
||||
final Long commandId = ((Number) entry.get("id")).longValue();
|
||||
final String voucherNo = switch (status) {
|
||||
case STATUS_PROCESSED -> voucherFor(commandId, entity, action, resourceId, json, (String) entry.get("madeOn"));
|
||||
@@ -1054,86 +428,10 @@ public class DdsApprovalReadService {
|
||||
return found == null ? "Agent #" + agentId : found.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A DDS account's number, holder, scheme, branch and agent. An edit reads the account as it
|
||||
* stands; a new account does not exist until approved, so it is read from the request, and has
|
||||
* no number yet.
|
||||
*/
|
||||
private Map<String, Object> accountColumns(final Long partyId, final JsonObject json) {
|
||||
final Map<String, Object> columns = new LinkedHashMap<>();
|
||||
if (partyId != null) {
|
||||
final Map<String, Object> stored = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select p.account_no as \"accountNo\", p.party_name as \"clientName\", p.sub_type as \"subType\", "
|
||||
+ "o.name as \"officeName\", trim(a.agent_code || ' ' || coalesce(a.agent_name, '')) as \"agentName\" "
|
||||
+ "from m_dds_party p left join m_office o on o.id = p.office_id "
|
||||
+ "left join m_dds_agent a on a.id = p.agent_id where p.id = ?",
|
||||
partyId).stream().findFirst().orElse(Map.of());
|
||||
columns.putAll(stored);
|
||||
} else {
|
||||
columns.put("accountNo", null);
|
||||
columns.put("clientName", text(json, "partyName"));
|
||||
columns.put("subType", text(json, "subType"));
|
||||
final Long officeId = longOf(json, "officeId");
|
||||
columns.put("officeName", officeId == null ? null : first("select name from m_office where id = ?", officeId));
|
||||
final Long agentId = longOf(json, "agentId");
|
||||
columns.put("agentName", agentId == null ? null : agent(agentId));
|
||||
}
|
||||
final String subType = (String) columns.remove("subType");
|
||||
final String description = subType == null ? null
|
||||
: first("select account_type_description from m_dds_account_type where account_type_code = ?", subType);
|
||||
columns.put("productName", description == null ? subType : subType + " · " + description);
|
||||
return columns;
|
||||
}
|
||||
|
||||
private String scheme(final Long accountTypeId) {
|
||||
return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pending premature closure will pay the customer - not the bare balance, which leaves out the
|
||||
* charge. The same sums the closure makes: the customer bearing it gives back the credited interest
|
||||
* scaled by the share of the rate given up; the agent bearing it adds interest on the balance from
|
||||
* the closing day to the month's end.
|
||||
*/
|
||||
private BigDecimal prematurePayout(final Long partyId, final JsonObject json) {
|
||||
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select p.current_balance as \"balance\", p.interest_rate as \"rate\", t.premature_interest_reduction as \"reduction\" "
|
||||
+ "from m_dds_party p left join m_dds_account_type t on t.account_type_code = p.sub_type where p.id = ?",
|
||||
partyId);
|
||||
if (found.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final BigDecimal rate = (BigDecimal) found.get(0).get("rate");
|
||||
final LocalDate closeDate = DateUtils.getBusinessLocalDate();
|
||||
// The closure first credits the interest still owed through the day before it, so the figures start there.
|
||||
final BigDecimal owed = this.partyRepository.findById(partyId)
|
||||
.map(party -> this.batchRunWritePlatformService.interestNotYetPosted(party, closeDate.minusDays(1))).orElse(BigDecimal.ZERO);
|
||||
final BigDecimal stored = (BigDecimal) found.get(0).get("balance");
|
||||
final BigDecimal balance = stored == null ? null : stored.add(owed);
|
||||
if (balance == null || rate == null || rate.signum() <= 0) {
|
||||
return balance;
|
||||
}
|
||||
if ("CUSTOMER".equals(text(json, "shortfallBorneBy"))) {
|
||||
final BigDecimal reduction = (BigDecimal) found.get(0).get("reduction");
|
||||
if (reduction == null || reduction.signum() <= 0) {
|
||||
return balance;
|
||||
}
|
||||
final BigDecimal creditedSoFar = this.jdbcTemplate.queryForObject( // NOSONAR
|
||||
"select coalesce(sum(case when direction = 'CREDIT' then amount else -amount end), 0) from m_dds_party_transaction "
|
||||
+ "where party_id = ? and transaction_type in ('INTEREST_POST', 'PREMATURE_INT_RECOVERY', 'PREMATURE_AGENT_TOPUP')",
|
||||
BigDecimal.class, partyId);
|
||||
final BigDecimal credited = creditedSoFar == null ? owed : creditedSoFar.add(owed);
|
||||
if (credited == null || credited.signum() <= 0) {
|
||||
return balance;
|
||||
}
|
||||
final BigDecimal recovered = credited.multiply(reduction.min(rate)).divide(rate, MathContext.DECIMAL64).setScale(2,
|
||||
RoundingMode.HALF_UP);
|
||||
return balance.subtract(recovered);
|
||||
}
|
||||
final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth() + 1;
|
||||
return balance.add(DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate));
|
||||
}
|
||||
|
||||
private BigDecimal balanceOf(final Long partyId) {
|
||||
final List<BigDecimal> found = this.jdbcTemplate.queryForList("select current_balance from m_dds_party where id = ?", // NOSONAR
|
||||
BigDecimal.class, partyId);
|
||||
|
||||
-17
@@ -22,7 +22,6 @@ 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;
|
||||
@@ -39,8 +38,6 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/dds/batchruns")
|
||||
@@ -55,20 +52,6 @@ public class BatchRunsApiResource {
|
||||
private final BatchRunReadPlatformService readPlatformService;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final DdsVoucherService ddsVoucherService;
|
||||
private final BatchRunWritePlatformService writePlatformService;
|
||||
|
||||
@DELETE
|
||||
@Path("{batchRunId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Discard a previewed DDS Batch Run", description = "Deletes a run that was generated but never posted - "
|
||||
+ "the preview the screen's Discard button throws away. Refused for a run that is posted, posting, failed, or whose "
|
||||
+ "posting is waiting for approval.")
|
||||
public CommandProcessingResult discard(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) {
|
||||
// Whoever may generate a preview may throw one away; it moved nothing.
|
||||
this.context.authenticatedUser().validateHasPermissionTo("GENERATE_" + RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
this.writePlatformService.discardBatchRun(batchRunId);
|
||||
return new CommandProcessingResultBuilder().withEntityId(batchRunId).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
-7
@@ -70,13 +70,6 @@ public class PostDdsInterestTasklet implements Tasklet {
|
||||
log.info("Post DDS Interest: brought {} matured account(s) up to their maturity date as on {}", maturedPosted, businessDate);
|
||||
}
|
||||
|
||||
// After the interest above, so an account matures with its interest brought up to date.
|
||||
final int matured = this.batchRunWritePlatformService.markMaturedAccounts(businessDate);
|
||||
|
||||
if (matured > 0) {
|
||||
log.info("Post DDS Interest: marked {} account(s) MATURED as on {}", matured, businessDate);
|
||||
}
|
||||
|
||||
final int promoted = this.batchRunWritePlatformService.promoteMinorsWhoHaveComeOfAge(businessDate);
|
||||
|
||||
if (promoted == 0) {
|
||||
|
||||
-13
@@ -30,12 +30,6 @@ public interface BatchRunWritePlatformService {
|
||||
|
||||
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* Throws away a run that was only previewed - generated and never posted, nor waiting for a
|
||||
* checker to approve its posting. Nothing was applied by generating it, so nothing is undone.
|
||||
*/
|
||||
void discardBatchRun(Long batchRunId);
|
||||
|
||||
/**
|
||||
* Posts interest for every scheme whose Account Type says to do it automatically and whose posting
|
||||
* day falls on the given date. Driven by the Post DDS Interest scheduled job.
|
||||
@@ -83,13 +77,6 @@ public interface BatchRunWritePlatformService {
|
||||
*/
|
||||
int promoteMinorsWhoHaveComeOfAge(LocalDate businessDate);
|
||||
|
||||
/**
|
||||
* Marks open accounts whose maturity date has come as MATURED, as FD's maturity job does. Run nightly.
|
||||
*
|
||||
* @return how many accounts matured.
|
||||
*/
|
||||
int markMaturedAccounts(LocalDate businessDate);
|
||||
|
||||
/**
|
||||
* Credits every row on a run and marks it posted. Called from the background thread that
|
||||
* {@code postBatchRun} hands the work to, not from a request.
|
||||
|
||||
+49
-222
@@ -24,14 +24,11 @@ import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
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.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
@@ -86,7 +83,6 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
* re-running it for the same period finds no days left; a posting takes its amount off the parked
|
||||
* figure, so re-posting finds nothing left to move.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
|
||||
|
||||
@@ -145,13 +141,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
private static final String COMMISSION_ACCOUNT_SQL = "select a.id, a.status_enum, a.product_id, p.deposit_type_enum "
|
||||
+ "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?";
|
||||
|
||||
/**
|
||||
* Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
|
||||
* Read off the parties themselves rather than a configured product id: what makes an account a
|
||||
* scheme account is that a scheme runs on it, whatever product it was opened under.
|
||||
*/
|
||||
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
|
||||
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
|
||||
/** The product DDS opens customers' scheme accounts under - see 0492. */
|
||||
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
|
||||
private static final int SAVINGS_DEPOSIT_TYPE = 100;
|
||||
private static final int FIXED_DEPOSIT_TYPE = 200;
|
||||
@@ -182,12 +174,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
subType);
|
||||
|
||||
switch (jobType) {
|
||||
// A run on the screen counts up to the day before its job date: that day is still open, and a
|
||||
// deposit made after the run would otherwise lose its own day, which the next run starts after.
|
||||
case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate.minusDays(1),
|
||||
false, officeNames, subType);
|
||||
case OD_INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate.minusDays(1),
|
||||
true, officeNames, subType);
|
||||
case INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
|
||||
officeNames, subType);
|
||||
case OD_INTEREST_CALCULATION -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
|
||||
officeNames, subType);
|
||||
case INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
|
||||
officeNames, subType);
|
||||
case OD_INTEREST_POSTING -> generateInterestPostingRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
|
||||
@@ -199,67 +189,14 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
||||
}
|
||||
|
||||
// Nothing to calculate or post - the period is already done, or had no activity - so nothing is
|
||||
// saved: a run with no entries would only sit on the list as a second copy of one already there.
|
||||
if (batchRun.getRows().isEmpty()) {
|
||||
final Map<String, Object> nothing = new LinkedHashMap<>();
|
||||
nothing.put("nothingToApply", true);
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).with(nothing).build();
|
||||
}
|
||||
|
||||
// A new preview of the same job replaces the one before it: generating twice used to leave two
|
||||
// identical "not posted" runs on the list, and nobody could tell which one to post.
|
||||
discardSupersededPreviews(jobType, subType);
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
|
||||
final Map<String, Object> outcome = new LinkedHashMap<>();
|
||||
if (jobType.endsWith("_CALCULATION")) {
|
||||
// A calculation books nothing on the ledger - it records what has been earned against each account
|
||||
// in DDS's own tables - so there is nothing for a checker to approve and nothing to wait for:
|
||||
// it is applied as it is generated. What it recorded is then paid out by the Posting run, which
|
||||
// is the step that moves money and the one a checker approves.
|
||||
applyRows(batchRun);
|
||||
batchRun.markPosted(LocalDate.now());
|
||||
this.batchRunRepository.saveAndFlush(batchRun);
|
||||
outcome.put("calculated", true);
|
||||
}
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withCommandId(command.commandId()) //
|
||||
.withEntityId(batchRun.getId()) //
|
||||
.with(outcome) //
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* A run that is only a preview: calculated, never posted, not posting or failed, and with no
|
||||
* posting of it waiting for a checker (a held posting names the run, and approving it later must
|
||||
* find it). Only such a run is ever deleted - generating applies nothing, so nothing is lost.
|
||||
*/
|
||||
private static final String PREVIEW_ONLY = "r.status = 'CALCULATED' and not exists (select 1 from m_portfolio_command_source c "
|
||||
+ "where c.entity_name = 'DDSBATCH' and c.resource_id = r.id and c.status = 2)";
|
||||
|
||||
private void discardSupersededPreviews(final String jobType, final String subType) {
|
||||
final String sameJob = "r.job_type = ? and coalesce(r.sub_type, '') = coalesce(?, '') and " + PREVIEW_ONLY;
|
||||
this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id in (select r.id from m_dds_batch_run r where " // NOSONAR
|
||||
+ sameJob + ")", jobType, subType);
|
||||
this.jdbcTemplate.update("delete from m_dds_batch_run r where " + sameJob, jobType, subType); // NOSONAR
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void discardBatchRun(final Long batchRunId) {
|
||||
final List<Long> found = this.jdbcTemplate.queryForList("select r.id from m_dds_batch_run r where r.id = ? and " + PREVIEW_ONLY, // NOSONAR
|
||||
Long.class, batchRunId);
|
||||
if (found.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.batch.run.not.discardable",
|
||||
"Batch run #" + batchRunId + " cannot be discarded: only a run that was generated and not posted (or sent for"
|
||||
+ " posting approval) can be.");
|
||||
}
|
||||
this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id = ?", batchRunId); // NOSONAR
|
||||
this.jdbcTemplate.update("delete from m_dds_batch_run where id = ?", batchRunId); // NOSONAR
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched against
|
||||
* Agent.homeBranch by name, since that field is plain text (not a real Office FK) mirroring the
|
||||
@@ -289,7 +226,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Works out each account's interest since it was last calculated, up to and including {@code through}.
|
||||
* Works out each account's interest since it was last calculated, up to the job date.
|
||||
*
|
||||
* A daily-deposit balance is built up a little at a time, so the closing balance says nothing
|
||||
* about how long the money was actually held: an instalment paid on the last day of the month has
|
||||
@@ -298,7 +235,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
* the maturity figure quoted at account opening uses, so the two agree.
|
||||
*/
|
||||
private void generateInterestRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
|
||||
final Long partyTo, final LocalDate through, final boolean odInterest, final Set<String> officeNames,
|
||||
final Long partyTo, final LocalDate jobDate, final boolean odInterest, final Set<String> officeNames,
|
||||
final String subType) {
|
||||
|
||||
final List<Party> parties = odInterest
|
||||
@@ -315,7 +252,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
return;
|
||||
}
|
||||
|
||||
final Map<Long, List<PartyTransaction>> movements = movementsByParty(eligible, through);
|
||||
final Map<Long, List<PartyTransaction>> movements = movementsByParty(eligible, jobDate);
|
||||
|
||||
for (final Party party : eligible) {
|
||||
final BigDecimal rate = party.getInterestRate();
|
||||
@@ -328,15 +265,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
final LocalDate accrueFrom = party.interestCalculatedThrough(odInterest) != null
|
||||
? party.interestCalculatedThrough(odInterest)
|
||||
: party.getOpeningDate();
|
||||
// The period counted is after its start date, so a first run starts a day early: the opening
|
||||
// day itself earns, as every deposit's own day does.
|
||||
final LocalDate countedAfter = party.interestCalculatedThrough(odInterest) != null ? accrueFrom : accrueFrom.minusDays(1);
|
||||
if (!countedAfter.isBefore(through)) {
|
||||
if (!accrueFrom.isBefore(jobDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final BigDecimal dailyProduct = DdsInterestCalculation.dailyProduct(party.getOpeningBalance(),
|
||||
movementsOf(movements.getOrDefault(party.getId(), List.of())), countedAfter, through);
|
||||
movementsOf(movements.getOrDefault(party.getId(), List.of())), accrueFrom, jobDate);
|
||||
if (dailyProduct.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -347,7 +281,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
}
|
||||
|
||||
batchRun.addRow(
|
||||
BatchRunRow.forInterest(party.getId(), party.getAgent().getId(), interest, dailyProduct, rate, accrueFrom, through));
|
||||
BatchRunRow.forInterest(party.getId(), party.getAgent().getId(), interest, dailyProduct, rate, accrueFrom, jobDate));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,7 +447,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
|
||||
final DdsCommissionCalculation commission = DdsCommissionCalculation.of(collectionVolume,
|
||||
matchingSlabs.get(0).getCommissionPercent(), agent.isTdsApplicable(), agent.getTdsPercent(),
|
||||
agent.totalDeductions().add(prematureChargesOutstanding(agent.getId())));
|
||||
agent.totalDeductions());
|
||||
if (!commission.isPayable()) {
|
||||
// The deductions have swallowed this period's commission. Nothing is owed, and the
|
||||
// agent is not put into debt over it - the deductions are standing figures the bank
|
||||
@@ -662,47 +596,26 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
final String jobType = batchRun.getJobType();
|
||||
final LocalDate jobDate = batchRun.getJobDate();
|
||||
|
||||
// Only rows that actually moved money are counted, and their sum kept beside the run's total: a
|
||||
// posting row is passed over when another run has already paid what it lists, and the run must
|
||||
// not read as though it paid it again.
|
||||
int done = 0;
|
||||
BigDecimal moved = BigDecimal.ZERO;
|
||||
for (final BatchRunRow row : batchRun.getRows()) {
|
||||
final boolean applied = switch (jobType) {
|
||||
case INTEREST_CALCULATION -> {
|
||||
accrue(row, jobDate, false);
|
||||
yield true;
|
||||
}
|
||||
case OD_INTEREST_CALCULATION -> {
|
||||
accrue(row, jobDate, true);
|
||||
yield true;
|
||||
}
|
||||
switch (jobType) {
|
||||
case INTEREST_CALCULATION -> accrue(row, jobDate, false);
|
||||
case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true);
|
||||
case INTEREST_POSTING -> postInterest(row, batchRun, false);
|
||||
case OD_INTEREST_POSTING -> postInterest(row, batchRun, true);
|
||||
case COMMISSION_CALCULATION -> {
|
||||
accrueCommission(row, batchRun);
|
||||
yield true;
|
||||
}
|
||||
case COMMISSION_CALCULATION -> accrueCommission(row, batchRun);
|
||||
case COMMISSION_POSTING -> payCommission(row, batchRun);
|
||||
case MATURITY_AUTO_CLOSE -> {
|
||||
closeAtMaturity(row);
|
||||
yield true;
|
||||
}
|
||||
case MATURITY_AUTO_CLOSE -> closeAtMaturity(row);
|
||||
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
|
||||
};
|
||||
if (applied) {
|
||||
done++;
|
||||
moved = moved.add(row.getAmount() == null ? BigDecimal.ZERO : row.getAmount());
|
||||
} else {
|
||||
log.warn("DDS batch run #{}: row {} passed over - what it lists was already paid", batchRun.getId(), row.getId());
|
||||
}
|
||||
done++;
|
||||
}
|
||||
|
||||
// Only at the end. A crediting run is one transaction from start to finish, so a count written
|
||||
// part-way through would be invisible outside it until the run commits - and writing it from a
|
||||
// transaction of its own would have two of them contending for the same row. The status says
|
||||
// POSTING while it works, which is what a clerk actually needs to know.
|
||||
batchRun.recordPosted(done, moved);
|
||||
batchRun.recordProgress(done);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -739,7 +652,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
return;
|
||||
}
|
||||
|
||||
party.accrueInterest(row.getAmount(), row.getToDate(), odInterest);
|
||||
party.accrueInterest(row.getAmount(), jobDate, odInterest);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -749,10 +662,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
* same accrued interest would each list it in full, and paying both would credit interest that
|
||||
* was never earned.
|
||||
*/
|
||||
private boolean postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) {
|
||||
private void postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) {
|
||||
final Party party = partyOf(row.getPartyId());
|
||||
if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest);
|
||||
@@ -765,7 +678,6 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST";
|
||||
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType,
|
||||
DIRECTION_CREDIT, null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -795,57 +707,6 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
|
||||
final BigDecimal tds = row.getTdsAmount() == null ? BigDecimal.ZERO : row.getTdsAmount();
|
||||
agent.accrueCommission(row.getAmount(), tds, jobDate);
|
||||
|
||||
// The premature-closure charges this commission was cut by are now recovered - each once. They
|
||||
// are what the row deducted beyond the agent's standing deductions, taken oldest first.
|
||||
final BigDecimal deducted = row.getDeductionAmount() == null ? BigDecimal.ZERO : row.getDeductionAmount();
|
||||
BigDecimal forCharges = deducted.subtract(agent.totalDeductions());
|
||||
for (final Map<String, Object> charge : this.jdbcTemplate.queryForList(OUTSTANDING_CHARGES_SQL, agent.getId())) { // NOSONAR
|
||||
final BigDecimal amount = (BigDecimal) charge.get("amount");
|
||||
if (forCharges.compareTo(amount) < 0) {
|
||||
break;
|
||||
}
|
||||
this.jdbcTemplate.update("update m_dds_agent_premature_charge set recovered_batch_run_id = ? where id = ?", // NOSONAR
|
||||
batchRun.getId(), charge.get("id"));
|
||||
forCharges = forCharges.subtract(amount);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String OUTSTANDING_CHARGES_SQL = "select id, amount from m_dds_agent_premature_charge "
|
||||
+ "where agent_id = ? and recovered_batch_run_id is null order by id";
|
||||
|
||||
/** Premature-closure charges laid on the agent that no commission has yet been cut by. */
|
||||
private BigDecimal prematureChargesOutstanding(final Long agentId) {
|
||||
final BigDecimal total = this.jdbcTemplate.queryForObject( // NOSONAR
|
||||
"select coalesce(sum(amount), 0) from m_dds_agent_premature_charge where agent_id = ? and recovered_batch_run_id is null",
|
||||
BigDecimal.class, agentId);
|
||||
return total == null ? BigDecimal.ZERO : total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles the premature-closure charges a commission payment has now recovered.
|
||||
*
|
||||
* The commission was cut by them and booked net, so a penalty needs no entry of its own: the
|
||||
* commission expense is already that much lower - the clawback is a reversal of commission, as a
|
||||
* core banking system books it. Interest the agent funded is different: the bank booked it as
|
||||
* interest on the closing day, and the agent has now paid for it out of commission, so it moves from
|
||||
* the product's Interest Expense to its Agent Commission Expense.
|
||||
*/
|
||||
private void bookRecoveredPrematureCharges(final Agent agent, final BatchRun batchRun) {
|
||||
for (final Map<String, Object> charge : this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select id, party_id, charge_type, amount from m_dds_agent_premature_charge "
|
||||
+ "where agent_id = ? and recovered_batch_run_id is not null and booked_batch_run_id is null order by id",
|
||||
agent.getId())) {
|
||||
if ("INTEREST_TOPUP".equals(charge.get("charge_type"))) {
|
||||
final Long partyId = ((Number) charge.get("party_id")).longValue();
|
||||
final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
||||
this.ddsLedger.bookAgentInterestTopUpRecovery(party, batchRun.getJobDate(), (BigDecimal) charge.get("amount"),
|
||||
"Premature closure interest funded by agent on " + party.getPartyCode() + " recovered from "
|
||||
+ agent.getAgentCode() + "'s commission, batch run #" + batchRun.getId());
|
||||
}
|
||||
this.jdbcTemplate.update("update m_dds_agent_premature_charge set booked_batch_run_id = ? where id = ?", // NOSONAR
|
||||
batchRun.getId(), charge.get("id"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -855,12 +716,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
* customers and not yet handed to a branch, which commission has nothing to do with. The entry is
|
||||
* recorded against the balance as it stands, so the ledger reads consistently.
|
||||
*/
|
||||
private boolean payCommission(final BatchRunRow row, final BatchRun batchRun) {
|
||||
private void payCommission(final BatchRunRow row, final BatchRun batchRun) {
|
||||
final Agent agent = agentOf(row.getAgentId());
|
||||
// Only what is actually owed can be paid: two posting runs generated over the same owed
|
||||
// figure would each list it in full, and paying both would pay commission twice.
|
||||
if (agent.getCommission().compareTo(row.getAmount()) < 0) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
agent.payOutCommission(row.getAmount());
|
||||
|
||||
@@ -869,7 +730,6 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
this.partyTransactionRepository.save(new PartyTransaction(null, agent, batchRun.getJobDate(), "COMMISSION_POST", DIRECTION_CREDIT,
|
||||
null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(),
|
||||
agent.getAgentCurrentBalance()));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -913,8 +773,8 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
+ " Set an ordinary savings account for this agent");
|
||||
}
|
||||
|
||||
final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
|
||||
if (schemes != null && schemes > 0) {
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo
|
||||
+ " is a DDS scheme account belonging to a customer, so commission paid into it would be counted as their"
|
||||
+ " deposits. Set an ordinary savings account for this agent");
|
||||
@@ -927,27 +787,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
"account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet");
|
||||
}
|
||||
|
||||
// Each product the agent collected for books its share on its own commission account; the
|
||||
// collections the share was worked out on are then marked as paid for.
|
||||
final Map<GLAccount, BigDecimal> shares = this.ddsLedger.commissionSplit(agent.getId(), amount, batchRun.getId());
|
||||
this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
|
||||
"DDS commission, batch run #" + batchRun.getId(), shares);
|
||||
this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId());
|
||||
|
||||
// The TDS withheld when the commission now paid was calculated - the calculation runs whose
|
||||
// collections this payment has just covered.
|
||||
final BigDecimal tds = this.jdbcTemplate.queryForObject(PAID_COMMISSION_TDS_SQL, BigDecimal.class, agent.getId(), // NOSONAR
|
||||
agent.getId(), batchRun.getId());
|
||||
this.ddsLedger.bookCommissionTds(agent.getOffice(), agent.getId(), batchRun.getId(), batchRun.getJobDate(), tds,
|
||||
"TDS u/s 194H on DDS commission of " + agent.getAgentCode() + ", batch run #" + batchRun.getId());
|
||||
|
||||
bookRecoveredPrematureCharges(agent, batchRun);
|
||||
"DDS commission, batch run #" + batchRun.getId());
|
||||
}
|
||||
|
||||
private static final String PAID_COMMISSION_TDS_SQL = "select coalesce(sum(r.tds_amount), 0) from m_dds_batch_run_row r "
|
||||
+ "where r.agent_id = ? and r.batch_run_id in (select distinct pt.commission_batch_run_id from m_dds_party_transaction pt "
|
||||
+ "where pt.agent_id = ? and pt.commission_paid_batch_run_id = ?)";
|
||||
|
||||
/** Fineract's savings statuses, named the way the screens name them. */
|
||||
private String statusName(final int status) {
|
||||
return switch (status) {
|
||||
@@ -967,36 +810,28 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Posts interest for every scheme whose posting period ended yesterday.
|
||||
* Posts interest for every scheme that is due today.
|
||||
*
|
||||
* A scheme that posts automatically has decided it needs no review, so this does both halves in
|
||||
* one go: it calculates the interest since the last run and then posts it into the customers'
|
||||
* balances. Both halves go through the same code the screen's Post button uses, and both leave a
|
||||
* run behind, so an automatic posting is as visible and as auditable as a manual one.
|
||||
*
|
||||
* Interest posts at the end of the period - the last day of the month, quarter, half-year or
|
||||
* year - as it does on savings and fixed deposits, so there is no posting day to configure and
|
||||
* no month whose length can trip one up. The job runs just after midnight, so it looks back at
|
||||
* the day that has just closed: posting on the 1st through the last day of the previous month
|
||||
* takes in everything collected on that last day, which a run at 00:20 on the day itself would
|
||||
* not yet have seen.
|
||||
*
|
||||
* Runs are scoped to one scheme at a time, because the posting frequency belongs to the scheme.
|
||||
* Runs are scoped to one scheme at a time, because the posting day belongs to the scheme.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int postDueAutoInterest(final LocalDate businessDate) {
|
||||
final LocalDate periodEnd = businessDate.minusDays(1);
|
||||
int posted = 0;
|
||||
|
||||
for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findDueForAutoInterestPost()) {
|
||||
if (!isPeriodEnd(accountType, periodEnd)) {
|
||||
if (!isDueOn(accountType, businessDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String subType = accountType.getAccountTypeCode();
|
||||
calculateAndPost(INTEREST_CALCULATION, periodEnd, subType);
|
||||
calculateAndPost(INTEREST_POSTING, periodEnd, subType);
|
||||
calculateAndPost(INTEREST_CALCULATION, businessDate, subType);
|
||||
calculateAndPost(INTEREST_POSTING, businessDate, subType);
|
||||
posted++;
|
||||
}
|
||||
|
||||
@@ -1004,11 +839,19 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given date closes one of this scheme's posting periods: the last day of a month
|
||||
* the scheme's frequency posts in. February's end is the 28th or 29th, whichever the year has.
|
||||
* Whether this scheme's posting day falls on the given date.
|
||||
*
|
||||
* A day beyond the length of the month means the end of that month: a scheme set to 31 posts on
|
||||
* 31 January, 28 February and 30 April. Without that, February would simply be skipped and the
|
||||
* account would lose a month's interest every year.
|
||||
*/
|
||||
private boolean isPeriodEnd(final DdsAccountType accountType, final LocalDate date) {
|
||||
return monthIsDue(accountType.getAutoInterestPostWhen(), date) && date.getDayOfMonth() == date.lengthOfMonth();
|
||||
private boolean isDueOn(final DdsAccountType accountType, final LocalDate date) {
|
||||
if (!monthIsDue(accountType.getAutoInterestPostWhen(), date)) {
|
||||
return false;
|
||||
}
|
||||
final int configuredDay = accountType.getAutoInterestPostDay();
|
||||
final int effectiveDay = Math.min(configuredDay, date.lengthOfMonth());
|
||||
return date.getDayOfMonth() == effectiveDay;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1050,16 +893,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
public int postMaturityInterest(final LocalDate businessDate) {
|
||||
int posted = 0;
|
||||
for (final Party party : this.partyRepository.findMaturedWithInterestOutstanding(businessDate)) {
|
||||
// Interest runs to the day before maturity: n instalments earn n, n-1 ... 1 days, the basis the
|
||||
// maturity amount quoted at opening is worked out on. Counting maturity day as well would pay
|
||||
// every instalment a day more than the customer was promised.
|
||||
final LocalDate lastEarningDay = party.getMaturityDate().minusDays(1);
|
||||
if (party.interestCalculatedThrough(false) != null && !party.interestCalculatedThrough(false).isBefore(lastEarningDay)) {
|
||||
continue;
|
||||
}
|
||||
// Each account's last stretch of interest is its own slip.
|
||||
final BigDecimal paid = this.ddsLedger.withVoucher(DdsVoucherService.TYPE_INTEREST, DdsVoucherService.REF_PARTY,
|
||||
party.getId(), () -> postInterestThrough(party, lastEarningDay));
|
||||
party.getId(), () -> postInterestThrough(party, party.getMaturityDate()));
|
||||
if (paid.compareTo(BigDecimal.ZERO) > 0) {
|
||||
posted++;
|
||||
}
|
||||
@@ -1085,12 +921,6 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
* it cannot pay, so the turnover waits on their own KYC being verified - and until it is, the
|
||||
* payout refuses while collections carry on. Nothing is written for an account still waiting.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
public int markMaturedAccounts(final LocalDate businessDate) {
|
||||
return this.partyRepository.markMatured(businessDate);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public int promoteMinorsWhoHaveComeOfAge(final LocalDate businessDate) {
|
||||
@@ -1135,9 +965,8 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
return alreadyCalculated;
|
||||
}
|
||||
|
||||
// After this date is counted, so an account never calculated starts a day early and its opening day earns.
|
||||
final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false)
|
||||
: party.getOpeningDate().minusDays(1);
|
||||
: party.getOpeningDate();
|
||||
if (!accrueFrom.isBefore(through)) {
|
||||
return alreadyCalculated;
|
||||
}
|
||||
@@ -1170,9 +999,8 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
if (through == null || rate == null || rate.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return calculated;
|
||||
}
|
||||
// Counted after this date, as postInterestThrough counts, so the opening day earns.
|
||||
final LocalDate accrueFrom = party.interestCalculatedThrough(false) != null ? party.interestCalculatedThrough(false)
|
||||
: party.getOpeningDate() == null ? null : party.getOpeningDate().minusDays(1);
|
||||
: party.getOpeningDate();
|
||||
if (accrueFrom == null || !accrueFrom.isBefore(through)) {
|
||||
return calculated;
|
||||
}
|
||||
@@ -1205,7 +1033,6 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
|
||||
subType);
|
||||
|
||||
if (INTEREST_CALCULATION.equals(jobType)) {
|
||||
// The nightly job runs after the period's last day has closed, so that day is counted.
|
||||
generateInterestRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType);
|
||||
} else {
|
||||
generateInterestPostingRows(batchRun, 1L, Long.MAX_VALUE, 1L, Long.MAX_VALUE, jobDate, false, null, subType);
|
||||
|
||||
-11
@@ -115,7 +115,6 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
|
||||
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
|
||||
} else {
|
||||
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
|
||||
toThePaisa(v, decimalOf(row));
|
||||
}
|
||||
|
||||
final String mode = stringOf(row, "transactionMode");
|
||||
@@ -202,14 +201,4 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored
|
||||
* on the passbook and the journal as typed.
|
||||
*/
|
||||
private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) {
|
||||
if (amount != null && amount.stripTrailingZeros().scale() > 2) {
|
||||
v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+10
-33
@@ -49,7 +49,6 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
|
||||
import org.apache.fineract.organisation.agent.domain.Agent;
|
||||
import org.apache.fineract.organisation.agent.domain.AgentRepository;
|
||||
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
|
||||
import org.apache.fineract.organisation.office.domain.Office;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry;
|
||||
import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository;
|
||||
import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException;
|
||||
@@ -337,54 +336,32 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col
|
||||
}
|
||||
final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos);
|
||||
|
||||
// What the customers on DDS's own books are now owed, by the customer-deposits account of their
|
||||
// product and the branch they bank at - two products owe on two accounts, and a branch's DDS
|
||||
// Customer Deposits must add up to its own customers' balances.
|
||||
final String journalId = "DDSR-A" + applicationId;
|
||||
final Map<String, BigDecimal> pooled = new LinkedHashMap<>();
|
||||
final Map<String, GLAccount> depositAccounts = new LinkedHashMap<>();
|
||||
final Map<String, Office> offices = new LinkedHashMap<>();
|
||||
BigDecimal pooled = BigDecimal.ZERO;
|
||||
for (final CollectionEntry entry : pending) {
|
||||
final Party party = collectableParty(entry.getParty().getPartyCode(), agent);
|
||||
|
||||
final boolean pooledEntry = party.getSavingsAccountId() == null;
|
||||
if (!pooledEntry) {
|
||||
if (party.getSavingsAccountId() != null) {
|
||||
this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description);
|
||||
} else {
|
||||
final GLAccount deposits = this.ddsLedger.depositLiabilityOf(party);
|
||||
final Office office = this.ddsLedger.officeOf(party);
|
||||
final String key = office.getId() + ":" + deposits.getId();
|
||||
depositAccounts.putIfAbsent(key, deposits);
|
||||
offices.putIfAbsent(key, office);
|
||||
pooled.merge(key, entry.getAmount(), BigDecimal::add);
|
||||
pooled = pooled.add(entry.getAmount());
|
||||
}
|
||||
|
||||
// The customer's own statement records the day the money was collected, as it always has.
|
||||
// The agent's cash in hand is not raised: this cash was counted in at the counter, not
|
||||
// carried.
|
||||
final BigDecimal balanceAfter = party.credit(entry.getAmount());
|
||||
final PartyTransaction line = new PartyTransaction(party, agent, entry.getEntryDate(), "PARTY_TRAN", "CREDIT",
|
||||
entry.getTransactionMode(), "GL", receiptAccount.getGlCode(), entry.getAmount(), entry.getChequeNo(),
|
||||
entry.getTokenNo(), description, balanceAfter);
|
||||
// A pooled line's journal is written after the loop, so it is named here rather than taken
|
||||
// from whatever the operation wrote last.
|
||||
final PartyTransaction transaction = this.partyTransactionRepository
|
||||
.saveAndFlush(pooledEntry ? line.linkJournal(journalId) : line);
|
||||
final PartyTransaction transaction = this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent,
|
||||
entry.getEntryDate(), "PARTY_TRAN", "CREDIT", entry.getTransactionMode(), "GL", receiptAccount.getGlCode(),
|
||||
entry.getAmount(), entry.getChequeNo(), entry.getTokenNo(), description, balanceAfter));
|
||||
|
||||
entry.markSettledByReceipt(settledOn, transaction.getId(), applicationId);
|
||||
this.collectionEntryRepository.save(entry);
|
||||
}
|
||||
|
||||
// Everyone on DDS's own accounts, in one pair per branch and customer-deposits account, all under one journal id.
|
||||
boolean journalled = false;
|
||||
for (final Map.Entry<String, BigDecimal> owed : pooled.entrySet()) {
|
||||
if (owed.getValue().signum() > 0) {
|
||||
this.ddsLedger.applyAgentReceipts(offices.get(owed.getKey()), settledOn, owed.getValue(), receiptAccount,
|
||||
depositAccounts.get(owed.getKey()), journalId, description);
|
||||
journalled = true;
|
||||
}
|
||||
}
|
||||
if (journalled) {
|
||||
// Everyone on DDS's own accounts, in one pair.
|
||||
final String journalId = "DDSR-A" + applicationId;
|
||||
if (pooled.signum() > 0) {
|
||||
this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, pooled, receiptAccount, journalId, description);
|
||||
this.jdbcTemplate.update(SET_JOURNAL_SQL, journalId, applicationId); // NOSONAR
|
||||
}
|
||||
|
||||
|
||||
-64
@@ -30,10 +30,7 @@ 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.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -43,14 +40,9 @@ 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.UnrecognizedQueryParamException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
|
||||
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
|
||||
import org.apache.fineract.portfolio.dds.party.data.PartyData;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.Party;
|
||||
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
|
||||
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
|
||||
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -75,8 +67,6 @@ public class PartiesApiResource {
|
||||
* the account it opens and the entries it writes are their own record.
|
||||
*/
|
||||
private final PartyWritePlatformService partyWritePlatformService;
|
||||
private final PartyRepository partyRepository;
|
||||
private final BatchRunWritePlatformService batchRunWritePlatformService;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -105,19 +95,6 @@ public class PartiesApiResource {
|
||||
return readPlatformService.retrieveDueForClosure();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("summary")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "DDS book by agent", description = "One line per agent: accounts open and closed, the day's "
|
||||
+ "collection due, the balance held, what has been collected and how many accounts are behind their daily "
|
||||
+ "schedule. Worked out by the database, so the DDS accounts screen loads a few lines rather than every account; "
|
||||
+ "an agent's accounts are then read with ?agentId=.")
|
||||
public List<Map<String, Object>> retrieveAgentSummary() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
return readPlatformService.retrieveAgentSummary();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("creditaccounts")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -133,23 +110,6 @@ public class PartiesApiResource {
|
||||
return readPlatformService.retrieveCreditableAccounts(clientId);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("nextcode")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "What the Party Master Entry screen shows before saving",
|
||||
description = "partyCode: the code the next DDS party will be given - worked out the way saving does it (PT and one "
|
||||
+ "more than the highest number on the books), but only an estimate: another party saved first, or one "
|
||||
+ "waiting for approval, can take it. lastAccountNo: the number of the DDS account opened most recently, "
|
||||
+ "null when there is none. Both are for display; nothing is saved from them.")
|
||||
public Map<String, Object> retrieveNextPartyCode() {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("partyCode", readPlatformService.nextPartyCode());
|
||||
result.put("lastAccountNo", readPlatformService.lastAccountNo());
|
||||
return result;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("sundryaccounts")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@@ -164,30 +124,6 @@ public class PartiesApiResource {
|
||||
return ddsLedger.sundryAccounts();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{partyId}/interestdue")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Interest a DDS account has earned but not yet been credited",
|
||||
description = "Interest calculated or still to calculate through the day before the business date - what an "
|
||||
+ "early closure today credits before it settles. Read only; nothing is posted.")
|
||||
public Map<String, Object> retrieveInterestDue(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
|
||||
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
|
||||
final Party party = partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
// The closing day earns nothing - the money leaves that day - so interest is owed through the day before.
|
||||
final LocalDate through = businessDate.minusDays(1);
|
||||
final BigDecimal due = batchRunWritePlatformService.interestNotYetPosted(party, through);
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("businessDate", businessDate.toString());
|
||||
result.put("through", through.toString());
|
||||
result.put("creditedThrough", party.getInterestDate() == null ? null : party.getInterestDate().toString());
|
||||
result.put("interestDue", due);
|
||||
// What closing early today charges the agent, worked out as the closure works it out.
|
||||
result.put("agentPenalty", partyWritePlatformService.previewAgentPenalty(partyId));
|
||||
return result;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{partyId}/closure")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
+9
-19
@@ -22,7 +22,6 @@ import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
@@ -42,11 +41,11 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
|
||||
* Open accounts still assigned to this agent. Nobody collects for an agent who has been switched
|
||||
* off, so these would simply stop being collected from without anyone being told.
|
||||
*/
|
||||
@Query("select count(p) from Party p where p.agent.id = :agentId and p.status in ('ACTIVE', 'MATURED')")
|
||||
@Query("select count(p) from Party p where p.agent.id = :agentId and p.status = 'ACTIVE'")
|
||||
Long countActivePartiesOfAgent(@Param("agentId") Long agentId);
|
||||
|
||||
/** Open accounts on a scheme, which take its rate when the rate is changed. */
|
||||
@Query("select p from Party p where p.subType = :subType and p.status in ('ACTIVE', 'MATURED')")
|
||||
@Query("select p from Party p where p.subType = :subType and p.status = 'ACTIVE'")
|
||||
List<Party> findActiveOnSubType(@Param("subType") String subType);
|
||||
|
||||
/**
|
||||
@@ -55,12 +54,12 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
|
||||
* draw on and put the DDS balances out of step with what was actually paid.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType)")
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType)")
|
||||
List<Party> findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType) "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) "
|
||||
+ "and (p.od1Flag = true or p.od2Flag = true)")
|
||||
List<Party> findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
@@ -71,12 +70,12 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
|
||||
* rows.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType) and p.interestCalc > 0")
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.interestCalc > 0")
|
||||
List<Party> findWithAccruedInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType) and p.odInterestCalc > 0")
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) and p.odInterestCalc > 0")
|
||||
List<Party> findWithAccruedOdInterest(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
|
||||
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo, @Param("subType") String subType);
|
||||
|
||||
@@ -99,7 +98,7 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
|
||||
* have waited longest are listed at the top of what the clerk reviews.
|
||||
*/
|
||||
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
|
||||
+ "and p.status in ('ACTIVE', 'MATURED') and (:subType is null or p.subType = :subType) "
|
||||
+ "and p.status = 'ACTIVE' and (:subType is null or p.subType = :subType) "
|
||||
+ "and p.maturityDate is not null and p.maturityDate <= :asOn and p.currentBalance > 0 "
|
||||
+ "and lower(p.advCode) = 'transfer to saving' "
|
||||
+ "and p.creditToAccount is not null and length(trim(p.creditToAccount)) > 0 "
|
||||
@@ -119,7 +118,7 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
|
||||
* Maturity is the one date the money cannot wait for the next run, so these are found on their
|
||||
* own and brought up to date. Anything already posted through its maturity date is left alone.
|
||||
*/
|
||||
@Query("select p from Party p where p.status in ('ACTIVE', 'MATURED') and p.maturityDate is not null and p.maturityDate <= :asOn "
|
||||
@Query("select p from Party p where p.status = 'ACTIVE' and p.maturityDate is not null and p.maturityDate <= :asOn "
|
||||
+ "and (p.interestDate is null or p.interestDate < p.maturityDate) order by p.maturityDate, p.id")
|
||||
List<Party> findMaturedWithInterestOutstanding(@Param("asOn") LocalDate asOn);
|
||||
|
||||
@@ -129,16 +128,7 @@ public interface PartyRepository extends JpaRepository<Party, Long> {
|
||||
* Found on their birthday and not before, but turned over only once their own KYC is verified -
|
||||
* the caller checks that, because the answer lives on the bank's KYC record rather than here.
|
||||
*/
|
||||
@Query("select p from Party p where p.status in ('ACTIVE', 'MATURED') and lower(p.adultMinor) = 'minor' "
|
||||
@Query("select p from Party p where p.status = 'ACTIVE' and lower(p.adultMinor) = 'minor' "
|
||||
+ "and p.birthDate is not null and p.birthDate <= :bornOnOrBefore order by p.id")
|
||||
List<Party> findMinorsWhoHaveComeOfAge(@Param("bornOnOrBefore") LocalDate bornOnOrBefore);
|
||||
|
||||
/**
|
||||
* Turns open accounts whose maturity date has come to MATURED, as FD's maturity job does. They stay
|
||||
* open - collections, interest and the payout still reach them - until the payout closes them.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query("update Party p set p.status = 'MATURED' where p.status = 'ACTIVE' and p.maturityDate is not null "
|
||||
+ "and p.maturityDate <= :asOn")
|
||||
int markMatured(@Param("asOn") LocalDate asOn);
|
||||
}
|
||||
|
||||
-21
@@ -19,7 +19,6 @@
|
||||
package org.apache.fineract.portfolio.dds.party.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.fineract.portfolio.dds.party.data.PartyData;
|
||||
|
||||
@@ -36,19 +35,6 @@ public interface PartyReadPlatformService {
|
||||
*/
|
||||
Collection<Map<String, Object>> retrieveCreditableAccounts(Long clientId);
|
||||
|
||||
/**
|
||||
* The code the next party saved will be given, for a screen to show before it is saved. It is worked
|
||||
* out the way saving does it - "PT" and one more than the highest number on the books - but only an
|
||||
* estimate: another party saved first, or one waiting for approval, can take it.
|
||||
*/
|
||||
String nextPartyCode();
|
||||
|
||||
/**
|
||||
* The account number of the DDS account opened most recently, or null when none has been - what the
|
||||
* Party Master Entry screen shows as Last A/c. Read only: nothing is saved from it.
|
||||
*/
|
||||
String lastAccountNo();
|
||||
|
||||
/**
|
||||
* How a closed account was closed - early or at maturity, by whom, what the balance came to and
|
||||
* where it went - gathered from its transactions, the ledger and the command that closed it.
|
||||
@@ -74,11 +60,4 @@ public interface PartyReadPlatformService {
|
||||
* told.
|
||||
*/
|
||||
Collection<PartyData> retrieveDueForClosure();
|
||||
|
||||
/**
|
||||
* Each agent's DDS book in one line - accounts open and closed, the day's collection due, the balance
|
||||
* held, what has been collected and how many accounts are behind - worked out by the database, so
|
||||
* the DDS accounts screen opens on a few lines however many accounts there are.
|
||||
*/
|
||||
List<Map<String, Object>> retrieveAgentSummary();
|
||||
}
|
||||
|
||||
+5
-64
@@ -104,10 +104,7 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
+ " p.freeze_code as freezeCode, p.adv_code as advCode, p.credit_to_branch as creditToBranch, "
|
||||
+ " p.credit_to_account_type as creditToAccountType, p.credit_to_account as creditToAccount, "
|
||||
+ " p.nominee_applicable as nomineeApplicable, p.nominee_split_mode as nomineeSplitMode, "
|
||||
+ " p.status as status, p.close_date as closeDate, p.account_type_id as accountTypeId, "
|
||||
+ " p.currency_code as currencyCode, p.activatedon_date as activatedOnDate, "
|
||||
+ " (select u.username from m_appuser u where u.id = p.activatedon_userid) as activatedByUsername, "
|
||||
+ " (select u.username from m_appuser u where u.id = p.closedon_userid) as closedByUsername "
|
||||
+ " p.status as status, p.close_date as closeDate "
|
||||
// Left join: sub_type is a plain code column rather than a foreign key, so a party
|
||||
// whose account type was renamed away or predates the master still lists, just
|
||||
// without a description.
|
||||
@@ -217,11 +214,6 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
.nomineeSplitMode(rs.getString("nomineeSplitMode")) //
|
||||
.status(rs.getString("status")) //
|
||||
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
|
||||
.accountTypeId(JdbcSupport.getLong(rs, "accountTypeId")) //
|
||||
.currencyCode(rs.getString("currencyCode")) //
|
||||
.activatedOnDate(JdbcSupport.getLocalDate(rs, "activatedOnDate")) //
|
||||
.activatedByUsername(rs.getString("activatedByUsername")) //
|
||||
.closedByUsername(rs.getString("closedByUsername")) //
|
||||
.nominees(new ArrayList<>()) //
|
||||
.build();
|
||||
}
|
||||
@@ -275,44 +267,11 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
// Matured as of the bank's business date - the date the payout itself checks - not the
|
||||
// server's clock, so the list never offers an account the payout would call not yet matured.
|
||||
final String sql = "select " + PARTY_MAPPER.schema()
|
||||
+ " where p.status in ('ACTIVE', 'MATURED') and p.maturity_date is not null and p.maturity_date <= ?"
|
||||
+ " where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date <= ?"
|
||||
+ " order by p.maturity_date, p.party_code";
|
||||
return attachNominees(this.jdbcTemplate.query(sql, PARTY_MAPPER, DateUtils.getBusinessLocalDate())); // NOSONAR
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per agent, added up in the database rather than by sending every account to the screen.
|
||||
*
|
||||
* "Behind" is the rule DdsSchedule applies to one account - collected less than the instalments due
|
||||
* by today, counting from the opening day and stopping at the term's length - written as SQL so the
|
||||
* count agrees with the Schedule column on the agent's own accounts. Collections are summed once
|
||||
* per account in the derived table, which the partial index on PARTY_TRAN credits answers alone.
|
||||
*/
|
||||
private static final String AGENT_SUMMARY_SQL = "select a.id as \"agentId\", a.agent_code as \"agentCode\", "
|
||||
+ "a.agent_name as \"agentName\", o.name as \"officeName\", count(p.id) as \"accounts\", "
|
||||
+ "sum(case when p.status not in ('CLOSED', 'PREMATURE_CLOSED') then 1 else 0 end) as \"open\", "
|
||||
+ "sum(case when p.status in ('CLOSED', 'PREMATURE_CLOSED') then 1 else 0 end) as \"closed\", "
|
||||
// Matured: marked so by the nightly job, or past its maturity date before the job has run.
|
||||
+ "sum(case when p.status = 'MATURED' or (p.status = 'ACTIVE' and p.maturity_date <= current_date) "
|
||||
+ "then 1 else 0 end) as \"matured\", "
|
||||
+ "coalesce(sum(case when p.status = 'MATURED' or (p.status = 'ACTIVE' and p.maturity_date <= current_date) "
|
||||
+ "then p.current_balance end), 0) as \"maturedBalance\", "
|
||||
+ "sum(case when p.status = 'PREMATURE_CLOSED' then 1 else 0 end) as \"prematureClosed\", "
|
||||
+ "coalesce(sum(case when p.status not in ('CLOSED', 'PREMATURE_CLOSED') then p.daily_deposit_amount end), 0) as \"dailyDue\", "
|
||||
+ "coalesce(sum(p.current_balance), 0) as \"balance\", coalesce(sum(c.collected), 0) as \"collected\", "
|
||||
+ "sum(case when p.status not in ('CLOSED', 'PREMATURE_CLOSED') and p.daily_deposit_amount > 0 and coalesce(c.collected, 0) < "
|
||||
+ "p.daily_deposit_amount * least(greatest((current_date - p.opening_date) + 1, 0), coalesce(p.days, 0)) "
|
||||
+ "then 1 else 0 end) as \"inArrears\" "
|
||||
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id left join m_office o on o.id = a.office_id "
|
||||
+ "left join (select t.party_id, sum(t.amount) as collected from m_dds_party_transaction t "
|
||||
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' group by t.party_id) c on c.party_id = p.id "
|
||||
+ "group by a.id, a.agent_code, a.agent_name, o.name order by a.agent_code";
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> retrieveAgentSummary() {
|
||||
return this.jdbcTemplate.queryForList(AGENT_SUMMARY_SQL); // NOSONAR
|
||||
}
|
||||
|
||||
@Override
|
||||
public PartyData retrieveOne(final Long partyId) {
|
||||
try {
|
||||
@@ -367,32 +326,14 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
+ "p.name as \"productName\", a.account_balance_derived as balance, 'SAVINGS' as \"accountKind\" from m_savings_account a "
|
||||
+ "join m_savings_product p on p.id = a.product_id "
|
||||
+ "where a.client_id = ? and a.status_enum = 300 and p.deposit_type_enum = 100 "
|
||||
+ "and not exists (select 1 from m_dds_party dp where dp.savings_account_id = a.id) "
|
||||
+ "and a.product_id is distinct from (select cast(value as bigint) from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true) "
|
||||
+ "union all "
|
||||
+ "select c.id as id, c.account_no as \"accountNo\", cp.name as \"productName\", c.account_balance_derived as balance, "
|
||||
+ "'CURRENT' as \"accountKind\" from m_current_account c join m_current_product cp on cp.id = c.product_id "
|
||||
+ "where c.client_id = ? and c.status_enum = 300"
|
||||
+ ") accounts order by \"accountNo\"";
|
||||
|
||||
@Override
|
||||
public String nextPartyCode() {
|
||||
// The same figure saving a party works from - the highest number among every code on the books,
|
||||
// closed parties included - so what a screen shows is what saving would give it right now.
|
||||
final Long highest = this.jdbcTemplate.queryForObject( // NOSONAR
|
||||
"select coalesce(max(cast(substring(party_code, 3) as bigint)), 0) from m_dds_party where party_code like 'PT%'",
|
||||
Long.class);
|
||||
return String.format("PT%04d", (highest == null ? 0L : highest) + 1L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String lastAccountNo() {
|
||||
// The highest number on the DDS account serial - the account opened last. Closed accounts count:
|
||||
// this says what was opened, not what is still open.
|
||||
final List<String> found = this.jdbcTemplate.queryForList( // NOSONAR
|
||||
"select account_no from m_dds_party where account_no is not null order by account_no desc limit 1", String.class);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Map<String, Object>> retrieveCreditableAccounts(final Long clientId) {
|
||||
return this.jdbcTemplate.queryForList(CREDITABLE_ACCOUNTS_SQL, clientId, clientId); // NOSONAR
|
||||
@@ -444,7 +385,7 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
|
||||
}
|
||||
final Map<String, Object> party = parties.get(0);
|
||||
final Map<String, Object> closure = new LinkedHashMap<>();
|
||||
final boolean closed = "CLOSED".equals(party.get("status")) || "PREMATURE_CLOSED".equals(party.get("status"));
|
||||
final boolean closed = "CLOSED".equals(party.get("status"));
|
||||
closure.put("closed", closed);
|
||||
if (!closed) {
|
||||
return closure;
|
||||
|
||||
-7
@@ -96,11 +96,4 @@ public interface PartyWritePlatformService {
|
||||
* scheme's lock-in is not changed; only this account is let through it.
|
||||
*/
|
||||
CommandProcessingResult prematureCloseWithinLockin(Long partyId, JsonCommand command);
|
||||
|
||||
/**
|
||||
* What closing this account early today would charge its agent - read only: the account's collection,
|
||||
* the scheme's penalty rate, the agent's commission rate and what the account earned them, and the
|
||||
* penalty, which is never more than that.
|
||||
*/
|
||||
java.util.Map<String, Object> previewAgentPenalty(Long partyId);
|
||||
}
|
||||
|
||||
+77
-192
@@ -27,8 +27,8 @@ import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -79,10 +79,10 @@ import org.apache.fineract.portfolio.dds.party.exception.PartySubTypeNotActiveEx
|
||||
import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountDataDTO;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.orm.jpa.JpaSystemException;
|
||||
@@ -134,20 +134,15 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
final String partyCode = generateNextPartyCode();
|
||||
final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode,
|
||||
command);
|
||||
final DdsAccountType scheme = schemeOf(party);
|
||||
party.takeSchemeRate(scheme.getInterestRate());
|
||||
party.linkProduct(scheme.getId());
|
||||
// Operative from the day it is opened - by whoever executes this, the checker where the
|
||||
// opening went through maker-checker - in the bank's currency.
|
||||
party.recordActivation(organisationCurrency(), DateUtils.getBusinessLocalDate(), currentUserId());
|
||||
party.takeSchemeRate(schemeOf(party).getInterestRate());
|
||||
|
||||
linkSavingsAccountIfProvided(party, clientId, command);
|
||||
addNomineesFromCommand(party, command);
|
||||
|
||||
// The party is the DDS account: nothing is opened in the savings tables for it. Its number
|
||||
// is the next on the DDS account serial, taken in this transaction - so an opening that
|
||||
// fails or is rolled back hands its number back, and the numbers run without gaps.
|
||||
party.assignAccountNo(nextAccountSerial());
|
||||
// The party is the DDS account: nothing is opened in the savings tables for it. Its
|
||||
// number comes from its id, so it is given once the first save has made one.
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
party.assignAccountNo();
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder() //
|
||||
@@ -172,10 +167,6 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
this.fromApiJsonDeserializer.validateForUpdate(command.json());
|
||||
|
||||
final Party partyForUpdate = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
||||
refuseEditOfClosedAccount(partyForUpdate);
|
||||
// Asked before the update changes anything: is the branch this request names a different one?
|
||||
final boolean officeMoves = command.parameterExists(OFFICE_ID) && (partyForUpdate.getOffice() == null
|
||||
|| !partyForUpdate.getOffice().getId().equals(command.longValueOfParameterNamed(OFFICE_ID)));
|
||||
|
||||
// Checked against whoever owns the party once this update lands, not who owns it now -
|
||||
// moving the party to another customer and naming the old one's account in the same save
|
||||
@@ -185,16 +176,13 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
validateCreditToAccount(owningClientId, command);
|
||||
|
||||
final Map<String, Object> changesOnly = partyForUpdate.update(command);
|
||||
refuseChangeOfLockedTerms(partyForUpdate, changesOnly, officeMoves);
|
||||
|
||||
// Only when it actually changed: a party already sitting on a since-retired account type
|
||||
// must stay editable for everything else.
|
||||
if (changesOnly.containsKey("subType")) {
|
||||
validateSubType((String) changesOnly.get("subType"));
|
||||
// Moved to another scheme: it takes that scheme's rate, and is tied to it by id.
|
||||
final DdsAccountType newScheme = schemeOf(partyForUpdate);
|
||||
partyForUpdate.takeSchemeRate(newScheme.getInterestRate());
|
||||
partyForUpdate.linkProduct(newScheme.getId());
|
||||
// Moved to another scheme: it takes that scheme's rate.
|
||||
partyForUpdate.takeSchemeRate(schemeOf(partyForUpdate).getInterestRate());
|
||||
changesOnly.put("interestRate", partyForUpdate.getInterestRate());
|
||||
}
|
||||
|
||||
@@ -257,7 +245,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
}
|
||||
|
||||
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate");
|
||||
party.close(closeDate, currentUserId());
|
||||
party.close(closeDate);
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
|
||||
@@ -318,7 +306,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays a matured account out, and closes it (status CLOSED) on the same day.
|
||||
* Pays a matured account out. The account stays open.
|
||||
*
|
||||
* <p>
|
||||
* What is paid is what the account actually holds - the deposits that came in plus the interest
|
||||
@@ -334,6 +322,11 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
* no account given parked in Matured Deposits Payable, for the branch to send on
|
||||
* </pre>
|
||||
*
|
||||
* <p>
|
||||
* Closing is deliberately not part of this. Paying out and closing are two decisions: the officer
|
||||
* pays, sees the balance reach zero and the money land where it was meant to, and only then
|
||||
* closes. Doing both on one click would close accounts whose payment had not been checked, and a
|
||||
* closed account is far harder to put right than an open one with a balance.
|
||||
*/
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -354,7 +347,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
// open only leaves a second step to forget - an account that reads as active in every report and
|
||||
// that an agent could still collect on. The same as Maturity Auto Close does, on the day the
|
||||
// money moved.
|
||||
party.close(DateUtils.getBusinessLocalDate(), currentUserId());
|
||||
party.close(DateUtils.getBusinessLocalDate());
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
|
||||
@@ -393,8 +386,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
// not happen - the server was down, or the scheme has automatic posting switched off. Without
|
||||
// it a customer who paid every instalment still loses the last stretch of interest, and
|
||||
// nothing on screen would say so.
|
||||
// Through the day before maturity, as the maturity amount quoted at opening is worked out.
|
||||
this.batchRunWritePlatformService.postInterestThrough(party, party.getMaturityDate().minusDays(1));
|
||||
this.batchRunWritePlatformService.postInterestThrough(party, party.getMaturityDate());
|
||||
|
||||
final BigDecimal amount = party.getCurrentBalance();
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
@@ -453,7 +445,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
// The balance is nil now, which is the one thing closing insists on. Closed on the business
|
||||
// date rather than the maturity date: this is the day the money actually moved, and a close
|
||||
// date earlier than its own payout would read as though the account had been shut first.
|
||||
party.close(DateUtils.getBusinessLocalDate(), currentUserId());
|
||||
party.close(DateUtils.getBusinessLocalDate());
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
return new CommandProcessingResultBuilder().withEntityId(partyId).build();
|
||||
@@ -686,8 +678,9 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
// Sending one maturity into another moves money between two collection accounts instead of
|
||||
// handing it back, and if that scheme has already closed the money is stranded in an account
|
||||
// nobody can operate. The same refusal an agent's commission account gets.
|
||||
final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
|
||||
if (schemes != null && schemes > 0) {
|
||||
final Long productId = ((Number) account.get("product_id")).longValue();
|
||||
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
|
||||
throw new PartyCreditAccountUnusableException(accountNo, "it is a DDS scheme account, not an account to be paid into");
|
||||
}
|
||||
|
||||
@@ -712,13 +705,9 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
|
||||
* Read off the parties themselves rather than a configured product id: what makes an account a
|
||||
* scheme account is that a scheme runs on it, whatever product it was opened under.
|
||||
*/
|
||||
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
|
||||
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
|
||||
/** Set by 0492. Names the savings product a DDS customer's real account is opened under. */
|
||||
private static final String DEPOSIT_PRODUCT_SQL = "select value from c_configuration "
|
||||
+ "where name = 'dds-deposit-product' and enabled = true";
|
||||
|
||||
/**
|
||||
* Opens the customer's real deposit account, unless one was named and linked above.
|
||||
@@ -735,12 +724,24 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
return;
|
||||
}
|
||||
|
||||
// DDS keeps its customers' accounts itself (0514), with each DDS product's own ledger accounts
|
||||
// behind them, and no savings product is named for DDS any more - so there is no savings
|
||||
// account to open. A party's balance is already on the books through its product.
|
||||
final List<Long> productIds = this.jdbcTemplate.queryForList(DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR
|
||||
if (productIds.isEmpty() || productIds.get(0) == null) {
|
||||
throw new DdsDepositProductNotConfiguredException();
|
||||
}
|
||||
|
||||
final SavingsAccountDataDTO accountDetails = new SavingsAccountDataDTO(client, null, productIds.get(0), party.getOpeningDate(),
|
||||
this.context.authenticatedUser(), DateUtils.DEFAULT_DATE_FORMATTER);
|
||||
final CommandProcessingResult result = this.savingsApplicationProcessWritePlatformService.createActiveApplication(accountDetails);
|
||||
final Long savingsAccountId = result.getSavingsId() != null ? result.getSavingsId() : result.getResourceId();
|
||||
party.linkSavingsAccount(savingsAccountId);
|
||||
|
||||
// Withdrawals are blocked from the moment the account exists. A scheme account is a savings
|
||||
// account like any other, and left open a customer could empty a twelve-month scheme at the
|
||||
// counter the next day - which is the one thing daily deposit saving exists to prevent.
|
||||
// Deposits are untouched; only the way out is closed, and DDS lifts it for its own payouts.
|
||||
this.ddsLedger.lockDebits(savingsAccountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch an account belongs to, by its id.
|
||||
*
|
||||
@@ -749,68 +750,6 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
* counted at another. Refuses an id that names no branch rather than leaving the account
|
||||
* unplaced.
|
||||
*/
|
||||
/**
|
||||
* Who is doing this, for the account's own record of it. A background run has no logged-in user;
|
||||
* it is recorded as the system administrator, as the audit columns record it (AuditorAwareImpl).
|
||||
*/
|
||||
private Long currentUserId() {
|
||||
final AppUser user = this.context.getAuthenticatedUserIfPresent();
|
||||
return user != null ? user.getId() : SYSTEM_ADMIN_USER_ID;
|
||||
}
|
||||
|
||||
private static final Long SYSTEM_ADMIN_USER_ID = 1L;
|
||||
|
||||
/** The bank's currency - a DDS account is opened in it. */
|
||||
private String organisationCurrency() {
|
||||
final List<String> codes = this.jdbcTemplate.queryForList("select code from m_organisation_currency order by id limit 1", // NOSONAR
|
||||
String.class);
|
||||
return codes.isEmpty() ? null : codes.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an active DDS account's scheme is built on, and so cannot change while it runs - as a savings
|
||||
* account's product, currency and opening cannot: the customer and product, the branch whose books
|
||||
* carry it, the opening and its balance, and the deposit terms (daily amount, deposit, days, months)
|
||||
* that its maturity amount and interest are worked out from, with the dates and amounts that follow
|
||||
* from them. Changing one mid-scheme would leave the balance, the interest already posted and the
|
||||
* maturity figure answering to terms the account no longer has. The collecting agent is locked too:
|
||||
* the agent's pending collections, commission and cash in hand are counted against the account.
|
||||
* Contact details, nominees, lien and freeze, SMS/e-mail and where the maturity is paid stay editable.
|
||||
*/
|
||||
private static final List<String> LOCKED_WHEN_ACTIVE = List.of("clientId", "subType", "agentId", "openingDate", "openingBalance",
|
||||
"dailyDepositAmount", "depositAmount", "days", "month", "interestDate", "payDate", "paymentAmount", "reopenDate",
|
||||
"lastAccount");
|
||||
|
||||
private void refuseEditOfClosedAccount(final Party party) {
|
||||
if (party.isClosed()) {
|
||||
final String message = "DDS account " + party.getAccountNo() + " is closed and cannot be edited.";
|
||||
throw new PlatformApiDataValidationException("error.msg.dds.party.closed.not.editable", message,
|
||||
List.of(ApiParameterError.parameterError("error.msg.dds.party.closed.not.editable", message, "status")));
|
||||
}
|
||||
}
|
||||
|
||||
private void refuseChangeOfLockedTerms(final Party party, final Map<String, Object> changes, final boolean officeMoves) {
|
||||
if (party.isClosed()) {
|
||||
return;
|
||||
}
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
for (final String field : LOCKED_WHEN_ACTIVE) {
|
||||
if (changes.containsKey(field)) {
|
||||
errors.add(ApiParameterError.parameterError("error.msg.dds.party.term.locked." + field,
|
||||
"'" + field + "' cannot be changed on an active DDS account.", field));
|
||||
}
|
||||
}
|
||||
if (officeMoves) {
|
||||
errors.add(ApiParameterError.parameterError("error.msg.dds.party.term.locked." + OFFICE_ID,
|
||||
"The branch cannot be changed on an active DDS account.", OFFICE_ID));
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
// Thrown inside the update's transaction, so nothing the request changed is kept.
|
||||
throw new PlatformApiDataValidationException("error.msg.dds.party.terms.locked",
|
||||
"These cannot be changed on an active DDS account: " + errors.size() + " field(s).", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private Office officeNamed(final Long officeId) {
|
||||
return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId);
|
||||
}
|
||||
@@ -917,12 +856,24 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
// account agree on where the date comes from and on the format it arrives in.
|
||||
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate");
|
||||
|
||||
// The money leaves on the closing day, so that day earns nothing: interest is owed through the day before.
|
||||
final LocalDate lastEarningDay = closeDate.minusDays(1);
|
||||
// Interest still owed through that day - calculated by a run but not posted, or not calculated at
|
||||
// all - is worked out and credited here first, so the settlement below starts from everything
|
||||
// earned. The passbook shows it as its own line before the recovery or top-up.
|
||||
this.batchRunWritePlatformService.postInterestThrough(party, lastEarningDay);
|
||||
// The interest has to be brought up to date by the branch before the account is closed early:
|
||||
// calculated and posted through the closing day, so the passbook shows what was earned before
|
||||
// what is given back. Closing is refused until then, and says which runs are missing.
|
||||
final BigDecimal interestOwed = this.batchRunWritePlatformService.interestNotYetPosted(party, closeDate);
|
||||
if (interestOwed.compareTo(BigDecimal.ZERO) > 0) {
|
||||
final boolean calculatedOnly = party.accruedInterest(false) != null
|
||||
&& party.accruedInterest(false).compareTo(interestOwed) == 0;
|
||||
// A rule the request does not meet, not a permission it lacks - so a validation error.
|
||||
final String message = "Interest of ₹" + interestOwed.setScale(2, RoundingMode.HALF_UP) + " on " + party.getPartyCode()
|
||||
+ " is not posted yet. "
|
||||
+ (calculatedOnly ? "Run Interest Posting" : "Run Interest Calculation and then Interest Posting") + " for "
|
||||
+ closeDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) + ", then close the account.";
|
||||
throw new PlatformApiDataValidationException("error.msg.dds.party.interest.not.posted", message,
|
||||
List.of(ApiParameterError.generalError("error.msg.dds.party.interest.not.posted", message)));
|
||||
}
|
||||
|
||||
// Nothing is left to credit by now; kept so a rounding remainder is never lost.
|
||||
this.batchRunWritePlatformService.postInterestThrough(party, closeDate);
|
||||
|
||||
final boolean customerBears = CUSTOMER_BEARS.equals(command.stringValueOfParameterNamed(SHORTFALL_BORNE_BY));
|
||||
final BigDecimal borneAmount = customerBears
|
||||
@@ -933,7 +884,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
|
||||
payOutRemainder(party, command, closeDate, lockinOverrideReason);
|
||||
|
||||
party.closePremature(closeDate, currentUserId());
|
||||
party.close(closeDate);
|
||||
this.partyRepository.saveAndFlush(party);
|
||||
|
||||
final Map<String, Object> changes = new HashMap<>();
|
||||
@@ -1002,9 +953,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
}
|
||||
|
||||
final String description = "DDS premature closure interest recovery for " + party.getPartyCode();
|
||||
// Booked as the bank's income for the closure, not as interest never paid: the interest was
|
||||
// earned and paid at the time, and what comes back now is what closing early costs the customer.
|
||||
this.ddsLedger.recoverPrematureInterest(party, closeDate, recovered, description);
|
||||
this.ddsLedger.reverseInterest(party, closeDate, recovered, description);
|
||||
final BigDecimal balanceAfter = party.debit(recovered);
|
||||
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), closeDate, PREMATURE_INTEREST_RECOVERY,
|
||||
DIRECTION_DEBIT, null, null, null, recovered, null, null,
|
||||
@@ -1027,8 +976,11 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
// From the closing day to the month's end: the customer's own interest stops the day before closing.
|
||||
final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth() + 1;
|
||||
final int remainingDays = closeDate.lengthOfMonth() - closeDate.getDayOfMonth();
|
||||
if (remainingDays <= 0) {
|
||||
// Closed on the last day of the month - there is nothing left of it to fund.
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
final BigDecimal topUp = DdsInterestCalculation.interestOn(balance.multiply(BigDecimal.valueOf(remainingDays)), rate);
|
||||
if (topUp.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
@@ -1042,7 +994,8 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
DIRECTION_CREDIT, null, null, null, topUp, null, null,
|
||||
remainingDays + " days of " + closeDate.getMonth() + " funded by " + party.getAgent().getAgentCode(), balanceAfter));
|
||||
|
||||
recordAgentCharge(party, CHARGE_INTEREST_TOPUP, topUp, closeDate);
|
||||
party.getAgent().chargePrematureClosure(topUp);
|
||||
this.agentRepository.saveAndFlush(party.getAgent());
|
||||
return topUp;
|
||||
}
|
||||
|
||||
@@ -1050,80 +1003,26 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
* The agent's share, charged whichever way the interest was settled: an account closed early is a
|
||||
* round they did not keep going.
|
||||
*
|
||||
* Taken on this account alone, as a clawback of the commission it earned them - never on what the
|
||||
* agent is owed for everyone else, which would make one small account cost a busy agent more than it
|
||||
* ever earned them, and cost nothing on the day after their commission was paid.
|
||||
* Taken on what they are owed now rather than on this account's own collections, because
|
||||
* commission is earned per agent over a period and was never broken down per account - there is
|
||||
* no per-account figure to take a percentage of.
|
||||
*/
|
||||
private BigDecimal chargeAgentCommission(final Party party, final DdsAccountType scheme) {
|
||||
if (party.getAgent() == null) {
|
||||
final BigDecimal penaltyRate = scheme.getPrematureAgentCommissionPenalty();
|
||||
final Agent agent = party.getAgent();
|
||||
if (penaltyRate == null || penaltyRate.compareTo(BigDecimal.ZERO) <= 0 || agent == null
|
||||
|| agent.getCommission().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
final BigDecimal penalty = (BigDecimal) agentPenalty(party, scheme).get("penalty");
|
||||
final BigDecimal penalty = agent.getCommission().multiply(penaltyRate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
if (penalty.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
recordAgentCharge(party, CHARGE_PENALTY, penalty, DateUtils.getBusinessLocalDate());
|
||||
agent.chargePrematureClosure(penalty);
|
||||
this.agentRepository.saveAndFlush(agent);
|
||||
return penalty;
|
||||
}
|
||||
|
||||
private static final String ACCOUNT_COLLECTION_SQL = "select coalesce(sum(amount), 0) from m_dds_party_transaction "
|
||||
+ "where party_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT'";
|
||||
|
||||
/** The rate the agent's commission was last worked out at - their slab, as the last Commission Calculation found it. */
|
||||
private static final String AGENT_COMMISSION_RATE_SQL = "select r.rate_percent from m_dds_batch_run_row r "
|
||||
+ "join m_dds_batch_run b on b.id = r.batch_run_id where r.agent_id = ? and b.job_type = 'COMMISSION_CALCULATION' "
|
||||
+ "and r.rate_percent is not null order by b.id desc limit 1";
|
||||
|
||||
/**
|
||||
* The agent's premature-closure penalty on one account: the scheme's penalty rate on what was collected
|
||||
* on it, and never more than the commission that collection earned them at their slab rate. An agent
|
||||
* whose commission has not been worked out yet has no rate to cap by, and pays the scheme's rate.
|
||||
*/
|
||||
private Map<String, Object> agentPenalty(final Party party, final DdsAccountType scheme) {
|
||||
final BigDecimal collected = this.jdbcTemplate.queryForObject(ACCOUNT_COLLECTION_SQL, BigDecimal.class, party.getId()); // NOSONAR
|
||||
final BigDecimal collection = collected == null ? BigDecimal.ZERO : collected;
|
||||
final BigDecimal penaltyRate = scheme.getPrematureAgentCommissionPenalty() == null ? BigDecimal.ZERO
|
||||
: scheme.getPrematureAgentCommissionPenalty();
|
||||
final List<BigDecimal> rates = party.getAgent() == null ? List.of()
|
||||
: this.jdbcTemplate.queryForList(AGENT_COMMISSION_RATE_SQL, BigDecimal.class, party.getAgent().getId()); // NOSONAR
|
||||
final BigDecimal commissionRate = rates.isEmpty() ? null : rates.get(0);
|
||||
|
||||
BigDecimal penalty = collection.multiply(penaltyRate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
final BigDecimal earned = commissionRate == null ? null
|
||||
: collection.multiply(commissionRate).divide(HUNDRED, MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
if (earned != null && penalty.compareTo(earned) > 0) {
|
||||
penalty = earned;
|
||||
}
|
||||
final Map<String, Object> result = new HashMap<>();
|
||||
result.put("collection", collection);
|
||||
result.put("penaltyRate", penaltyRate);
|
||||
result.put("commissionRate", commissionRate);
|
||||
result.put("commissionEarned", earned);
|
||||
result.put("penalty", penalty.max(BigDecimal.ZERO));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> previewAgentPenalty(final Long partyId) {
|
||||
final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
|
||||
if (party.getAgent() == null) {
|
||||
return Map.of("penalty", BigDecimal.ZERO);
|
||||
}
|
||||
return agentPenalty(party, schemeOf(party));
|
||||
}
|
||||
|
||||
private static final String CHARGE_PENALTY = "PENALTY";
|
||||
private static final String CHARGE_INTEREST_TOPUP = "INTEREST_TOPUP";
|
||||
|
||||
/**
|
||||
* Holds a charge against the agent until a Commission Calculation takes it off their commission -
|
||||
* once. It used to go onto the agent's standing deduction, which every run deducts again.
|
||||
*/
|
||||
private void recordAgentCharge(final Party party, final String chargeType, final BigDecimal amount, final LocalDate chargedOn) {
|
||||
this.jdbcTemplate.update("insert into m_dds_agent_premature_charge (agent_id, party_id, charge_type, amount, charged_on) " // NOSONAR
|
||||
+ "values (?, ?, ?, ?, ?)", party.getAgent().getId(), party.getId(), chargeType, amount, chargedOn);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is left after the charge goes out the same way a matured payout does - to another account
|
||||
* of the customer's, or into matured-deposits payable when they have none.
|
||||
@@ -1171,20 +1070,6 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The next DDS account number. The update locks the serial's one row until this transaction ends,
|
||||
* so two openings at once queue on it rather than taking the same number, and a rollback undoes the
|
||||
* increment - unlike a sequence, which never gives a number back.
|
||||
*/
|
||||
private long nextAccountSerial() {
|
||||
final Long next = this.jdbcTemplate.queryForObject( // NOSONAR
|
||||
"update m_dds_account_number_serial set last_value = last_value + 1 where id = 1 returning last_value", Long.class);
|
||||
if (next == null) {
|
||||
throw new IllegalStateException("m_dds_account_number_serial has no row - migration 0624 has not run");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private String generateNextPartyCode() {
|
||||
final Long maxSuffix = this.partyRepository.retrieveMaxPartyCodeSuffix();
|
||||
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
|
||||
|
||||
+4
-4
@@ -111,7 +111,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
private DdsReportData agentCheckList() {
|
||||
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", a.agent_city as \"City\", "
|
||||
+ AGENT_STATUS_LABEL + " as \"Status\", a.agent_opening_date as \"Opening Date\", "
|
||||
+ "(select count(*) from m_dds_party p where p.agent_id = a.id and p.status in ('ACTIVE', 'MATURED')) as \"Accounts\", "
|
||||
+ "(select count(*) from m_dds_party p where p.agent_id = a.id and p.status = 'ACTIVE') as \"Accounts\", "
|
||||
+ AGENT_CASH_IN_HAND + " as \"Cash In Hand\", a.commission as \"Commission Due\" "
|
||||
+ "from m_dds_agent a order by a.agent_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql);
|
||||
@@ -307,7 +307,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
final Map<String, Object> agentTotals = this.jdbcTemplate.queryForMap(agentSql, agentFrom, agentTo);
|
||||
|
||||
final String partySql = "select count(*) as \"Total Accounts\", "
|
||||
+ "count(*) filter (where status in ('ACTIVE', 'MATURED')) as \"Active Accounts\", "
|
||||
+ "count(*) filter (where status = 'ACTIVE') as \"Active Accounts\", "
|
||||
+ "coalesce(sum(current_balance), 0) as \"Deposits Held\", "
|
||||
+ "coalesce(sum(interest_calc + od_interest_calc), 0) as \"Interest Accrued\" "
|
||||
+ "from m_dds_party where agent_id between ? and ? and id between ? and ?";
|
||||
@@ -364,7 +364,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
|
||||
// Open accounts only: this is the list payouts are arranged from, and a closed account
|
||||
// has nothing left to pay.
|
||||
+ "where p.status in ('ACTIVE', 'MATURED') and p.maturity_date is not null and p.maturity_date between ? and ? "
|
||||
+ "where p.status = 'ACTIVE' and p.maturity_date is not null and p.maturity_date between ? and ? "
|
||||
+ "and p.agent_id between ? and ? "
|
||||
+ "and p.id between ? and ? order by p.maturity_date, p.party_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
|
||||
@@ -465,7 +465,7 @@ public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformSe
|
||||
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", a.agent_code as \"Agent Code\", "
|
||||
+ "p.opening_date as \"Opening Date\", p.close_date as \"Close Date\", p.current_balance as \"Final Balance\" "
|
||||
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
|
||||
+ "where p.status in ('CLOSED', 'PREMATURE_CLOSED') and p.close_date between ? and ? and a.id between ? and ? and p.id between ? and ? "
|
||||
+ "where p.status = 'CLOSED' and p.close_date between ? and ? and a.id between ? and ? and p.id between ? and ? "
|
||||
+ "order by p.close_date, p.party_code";
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
|
||||
partyTo);
|
||||
|
||||
-14
@@ -22,7 +22,6 @@ import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
@@ -162,7 +161,6 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
} else {
|
||||
v.reset().parameter(COLLECTIONS).parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount"))
|
||||
.notNull().positiveAmount();
|
||||
toThePaisa(v, extractBigDecimal(row, "amount"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,11 +171,9 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
.notBlank();
|
||||
v.reset().parameter("withdrawAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element))
|
||||
.notNull().positiveAmount();
|
||||
toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element));
|
||||
} else if ("AGENT_TRAN".equals(transactionType)) {
|
||||
v.reset().parameter("amount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)).notNull()
|
||||
.positiveAmount();
|
||||
toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element));
|
||||
|
||||
// An agent handing its round's takings to the branch is a counter transaction like any
|
||||
// other, so it says how the money arrived on the same terms a collection does.
|
||||
@@ -211,14 +207,4 @@ public final class PartyTransactionCommandFromApiJsonDeserializer {
|
||||
dataValidationErrors);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored
|
||||
* on the passbook and the journal as typed.
|
||||
*/
|
||||
private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) {
|
||||
if (amount != null && amount.stripTrailingZeros().scale() > 2) {
|
||||
v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-14
@@ -45,13 +45,9 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
|
||||
+ " t.agent_id as agentId, a.agent_code as agentCode, t.transaction_date as transactionDate, "
|
||||
+ " t.transaction_type as transactionType, t.direction as direction, t.transaction_mode as transactionMode, "
|
||||
+ " t.account_type as accountType, t.account_no as accountNo, t.amount as amount, t.cheque_no as chequeNo, "
|
||||
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo, "
|
||||
+ " t.office_id as officeId, o.name as officeName, t.submitted_on_date as submittedOnDate, "
|
||||
+ " t.journal_transaction_id as journalTransactionId, t.ref_no as refNo, "
|
||||
+ " u.username as createdByUsername, t.created_date as createdDate, a.agent_name as agentName "
|
||||
+ " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo "
|
||||
+ " from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id "
|
||||
+ " join m_dds_agent a on a.id = t.agent_id left join m_office o on o.id = t.office_id "
|
||||
+ " left join m_appuser u on u.id = t.createdby_id";
|
||||
+ " join m_dds_agent a on a.id = t.agent_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -75,14 +71,6 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction
|
||||
.description(rs.getString("description")) //
|
||||
.balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) //
|
||||
.voucherNo(rs.getString("voucherNo")) //
|
||||
.officeId(JdbcSupport.getLong(rs, "officeId")) //
|
||||
.officeName(rs.getString("officeName")) //
|
||||
.submittedOnDate(JdbcSupport.getLocalDate(rs, "submittedOnDate")) //
|
||||
.journalTransactionId(rs.getString("journalTransactionId")) //
|
||||
.refNo(rs.getString("refNo")) //
|
||||
.createdByUsername(rs.getString("createdByUsername")) //
|
||||
.createdDate(rs.getTimestamp("createdDate") == null ? null : rs.getTimestamp("createdDate").toLocalDateTime()) //
|
||||
.agentName(rs.getString("agentName")) //
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.DdsTransactionMode;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
|
||||
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.InsufficientAgentBalanceException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.PartyAdvanceAmountMismatchException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.PartyArrearsDaysExceededException;
|
||||
import org.apache.fineract.portfolio.dds.transaction.exception.PartyHasNoDailyAmountException;
|
||||
@@ -275,13 +276,29 @@ public class PartyTransactionWritePlatformServiceJpaRepositoryImpl implements Pa
|
||||
// receipt tagged DDS into the agent's own GL account - and applied to the customers from Agent
|
||||
// Tran > Apply DDS Receipt. Taking the same cash in here as well would be a second way in for
|
||||
// one bag of notes, so this way is closed, and says where the right one is.
|
||||
if (agent != null) {
|
||||
throw new PlatformDataIntegrityException("error.msg.dds.agent.tran.moved.to.cashier",
|
||||
"An agent's cash is no longer taken in here. The cashier receipts it at Cashier > Transaction Entry "
|
||||
+ "(Receipt, Cash, GL, Entry Type DDS, A/C No " + (agent != null && agent.getGlAccount() != null
|
||||
? agent.getGlAccount().getGlCode()
|
||||
+ "(Receipt, Cash, GL, Entry Type DDS, A/C No " + (agent.getGlAccount() != null ? agent.getGlAccount().getGlCode()
|
||||
: "the agent's GL account") + "), and it is applied from Daily Transaction Detail > Agent Tran > "
|
||||
+ "Apply DDS Receipt.");
|
||||
}
|
||||
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("amount");
|
||||
if (agent.getAgentCurrentBalance().compareTo(amount) < 0) {
|
||||
throw new InsufficientAgentBalanceException(agent.getAgentCode());
|
||||
}
|
||||
final BigDecimal newBalance = agent.settle(amount);
|
||||
|
||||
// The cash reaches the branch. No customer is involved - they were credited when their money
|
||||
// was collected; this is the bank catching up with where the notes actually are.
|
||||
this.ddsLedger.settle(agent.getOffice(), transactionDate, amount, "DDS settlement by " + agent.getAgentCode(), agent.getGlAccount());
|
||||
|
||||
return new PartyTransaction(null, agent, transactionDate, "AGENT_TRAN", "DEBIT",
|
||||
DdsTransactionMode.canonicalOrOriginal(command.stringValueOfParameterNamedAllowingNull("transactionMode")),
|
||||
command.stringValueOfParameterNamedAllowingNull("accountType"),
|
||||
command.stringValueOfParameterNamedAllowingNull("accountNo"), amount, null, null,
|
||||
command.stringValueOfParameterNamedAllowingNull("description"), newBalance);
|
||||
}
|
||||
|
||||
private List<PartyTransaction> postWithdrawDeposite(final Agent agent, final LocalDate transactionDate,
|
||||
final JsonCommand command) {
|
||||
|
||||
+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));
|
||||
|
||||
@@ -821,3 +821,67 @@ fineract.npci.ifsc-prefix=${FINERACT_NPCI_IFSC_PREFIX:CBSH}
|
||||
# will fail and NPCI gracefully falls back to the poll model (GET /clearing/inward/{bankCode}).
|
||||
fineract.npci.inward-callback-url=${FINERACT_NPCI_INWARD_CALLBACK_URL:https://localhost:8443/fineract-provider/api/v1/npci/inward-push}
|
||||
fineract.npci.timeout-ms=${FINERACT_NPCI_TIMEOUT_MS:10000}
|
||||
|
||||
# ── I4C (NCRP-CFCFRMS) API Integration ──────────────────────────────────────
|
||||
# I4C-MHA "Banks API Integration Specification 2.0"; RBI letter CO.DOS.KYC.No.S6675/11-01-006/2025-26 (05 Dec 2025).
|
||||
# Off until a bank is onboarded: with enabled=false the inbound endpoints answer 503 and both jobs do nothing.
|
||||
# Every credential below comes from the environment; I4C sends the real values to the bank's Nodal/SPOC by email.
|
||||
fineract.i4c.enabled=${FINERACT_I4C_ENABLED:false}
|
||||
# I4C has no Fineract tenant header; its calls are served by this tenant.
|
||||
fineract.i4c.tenant-identifier=${FINERACT_I4C_TENANT:default}
|
||||
# This bank's code in the I4C Bank Code Master (m_i4c_bank_master), reported as root_bankid.
|
||||
fineract.i4c.bank-code=${FINERACT_I4C_BANK_CODE:0}
|
||||
fineract.i4c.bank-name=${FINERACT_I4C_BANK_NAME:}
|
||||
# Inbound (I4C -> bank): the username/password this bank issues to I4C. Give the bcrypt hash in production;
|
||||
# the plain password is accepted only when no hash is set (local and UAT).
|
||||
fineract.i4c.inbound.username=${FINERACT_I4C_INBOUND_USERNAME:}
|
||||
fineract.i4c.inbound.password-hash=${FINERACT_I4C_INBOUND_PASSWORD_HASH:}
|
||||
fineract.i4c.inbound.password=${FINERACT_I4C_INBOUND_PASSWORD:}
|
||||
fineract.i4c.inbound.token-ttl-seconds=${FINERACT_I4C_TOKEN_TTL_SECONDS:900}
|
||||
# Comma-separated source IPs I4C calls from (the public IPs I4C shares). Blank allows any IP - UAT only.
|
||||
fineract.i4c.inbound.allowed-ips=${FINERACT_I4C_ALLOWED_IPS:}
|
||||
# Trust X-Forwarded-For for the caller IP only when a reverse proxy in front of the CBS sets it.
|
||||
fineract.i4c.inbound.trust-forwarded-for=${FINERACT_I4C_TRUST_FORWARDED_FOR:false}
|
||||
# How a complaint push may authenticate (spec section 2: "Basic Auth / Auth 2.0", credentials "in every request"):
|
||||
# TOKEN = Bearer from /auth/token, BASIC = Authorization: Basic user:password, BODY = username/password (or
|
||||
# client_id/client_secret) inside the JSON. All check the same credentials and IP allowlist; list only what I4C uses.
|
||||
fineract.i4c.inbound.auth-modes=${FINERACT_I4C_AUTH_MODES:TOKEN,BASIC,BODY}
|
||||
# The client name the bank registers with I4C (spec: "client name, client ID, and client secret key"). The client ID
|
||||
# and secret are the inbound username and password above; /auth/token also accepts them as client_id/client_secret.
|
||||
fineract.i4c.inbound.client-name=${FINERACT_I4C_CLIENT_NAME:}
|
||||
# "Encryption Logics" shared by I4C. The specification's samples (U2FsdGVkX1...) are the CryptoJS / OpenSSL
|
||||
# passphrase format: AES-256-CBC, key and IV derived from the passphrase with EVP_BytesToKey(MD5).
|
||||
# FIELDS - only username/password are encrypted (what the specification shows)
|
||||
# ENVELOPE - the whole JSON body arrives as {"data":"<encrypted>"} and the bank answers the same way
|
||||
# NONE - plain JSON
|
||||
fineract.i4c.crypto.mode=${FINERACT_I4C_CRYPTO_MODE:FIELDS}
|
||||
fineract.i4c.crypto.passphrase=${FINERACT_I4C_CRYPTO_PASSPHRASE:}
|
||||
# Outbound (bank -> I4C): token then response. UAT/production URLs come from I4C.
|
||||
fineract.i4c.outbound.base-url=${FINERACT_I4C_OUTBOUND_BASE_URL:}
|
||||
fineract.i4c.outbound.auth-path=${FINERACT_I4C_OUTBOUND_AUTH_PATH:/auth/token}
|
||||
fineract.i4c.outbound.response-path=${FINERACT_I4C_OUTBOUND_RESPONSE_PATH:/bank/response}
|
||||
# Section 5 is titled "Ack Request Message from BANKS to I4C-MHA". The ack is always the reply to the push; set this
|
||||
# path only if I4C also wants it as a separate call (sent before the response). Blank = no separate ack call.
|
||||
fineract.i4c.outbound.ack-path=${FINERACT_I4C_OUTBOUND_ACK_PATH:}
|
||||
fineract.i4c.outbound.username=${FINERACT_I4C_OUTBOUND_USERNAME:}
|
||||
fineract.i4c.outbound.password=${FINERACT_I4C_OUTBOUND_PASSWORD:}
|
||||
fineract.i4c.outbound.timeout-ms=${FINERACT_I4C_OUTBOUND_TIMEOUT_MS:15000}
|
||||
fineract.i4c.outbound.max-attempts=${FINERACT_I4C_OUTBOUND_MAX_ATTEMPTS:8}
|
||||
# Incoming credits carry no RRN in this CBS, so a complaint's transaction is matched on account, amount and date,
|
||||
# within this many days either side of the reported date.
|
||||
fineract.i4c.match.date-window-days=${FINERACT_I4C_MATCH_WINDOW_DAYS:2}
|
||||
# Days a hold runs before it is due for review (MHA SOP: 90 days), and hours to intimate the customer.
|
||||
fineract.i4c.hold.review-days=${FINERACT_I4C_HOLD_REVIEW_DAYS:90}
|
||||
fineract.i4c.hold.intimation-hours=${FINERACT_I4C_HOLD_INTIMATION_HOURS:24}
|
||||
# MRM money restoration to another bank books through GLs mapped on Organisation > Clearing GL Configuration:
|
||||
# I4C_RESTORATION_PAYABLE, FUNDTRANSFER_OUTWARD_SUSPENSE and FUNDTRANSFER_SETTLEMENT (see 0614_i4c_sop_and_outward_ledger).
|
||||
# MRM manual: above this amount in a single complaint the IO must give an FIR.
|
||||
fineract.i4c.restoration.fir-threshold=${FINERACT_I4C_RESTORATION_FIR_THRESHOLD:50000}
|
||||
# Ack codes for a refused push: SECTION5 = always 11 "Structure validation failed"; SECTION7 = the specific section 7
|
||||
# code (40001 account number, 40004 amount, 40005 date). Repeated push: SAME_JOB = 00 with the original job_id;
|
||||
# CODE_50003 = 50003 "Duplicate Transaction". A transaction_date more than max-future-days ahead is refused (40005).
|
||||
fineract.i4c.ack.code-style=${FINERACT_I4C_ACK_CODE_STYLE:SECTION5}
|
||||
fineract.i4c.ack.duplicate-mode=${FINERACT_I4C_ACK_DUPLICATE_MODE:SAME_JOB}
|
||||
fineract.i4c.ack.max-future-days=${FINERACT_I4C_ACK_MAX_FUTURE_DAYS:1}
|
||||
# Local stand-in for I4C (token, response receiver, push). Never enable in production.
|
||||
fineract.i4c.simulator.enabled=${FINERACT_I4C_SIMULATOR_ENABLED:false}
|
||||
|
||||
+8
-11
@@ -189,15 +189,12 @@
|
||||
<include file="parts/0606_add_ctr_monthly_report_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0607_cc_relevant_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0618_drop_dds_account_type_post_day.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0619_dds_gl_six_digit_codes_and_agent_gl.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0620_dds_product_gl_mapping.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0621_dds_product_drop_unused_gl.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0622_dds_transaction_integrity.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0623_dds_account_audit_and_links.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0624_dds_account_number_serial.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0625_dds_default_commission_slabs.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0627_dds_party_collection_index.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0628_dds_party_matured_and_premature_status.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0629_dds_premature_income.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0610_cfcfrms_i4c_integration.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0611_i4c_bank_master_seed.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0612_i4c_intimation_channels.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0613_i4c_grm_mrm.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0614_i4c_sop_and_outward_ledger.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0615_i4c_exclusions_and_bank_holds.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0616_i4c_rbi_2026_liability_and_show_cause.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0617_i4c_ifsc_bank_code_map.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<!--
|
||||
I4C (NCRP-CFCFRMS) API integration, per the I4C-MHA "Banks API Integration Specification 2.0" and RBI letter
|
||||
CO.DOS.KYC.No.S6675/11-01-006/2025-26 (05 Dec 2025): real-time action on cyber-fraud complaints, and a record of the
|
||||
integration's performance for periodic review.
|
||||
|
||||
The demo tables of 0226 (m_cfcfrms_*) are untouched; this is a separate, real integration.
|
||||
|
||||
m_i4c_access_token tokens issued to I4C (hash only, never the token itself)
|
||||
m_i4c_request one complaint push from I4C: acknowledgement_no + the job_id the bank answered with
|
||||
m_i4c_incident the incidents[] of a push: rrn, amount, disputed amount, layer, and what was matched
|
||||
m_i4c_finding each transactions[] entry reported back (Put on Hold, Money Transfer To, ...)
|
||||
m_i4c_hold the register of holds placed for I4C: amount held, prospective shortfall, 90-day review,
|
||||
customer intimation
|
||||
m_i4c_hold_txn the account hold transactions behind one register entry (the first hold and any top-ups)
|
||||
m_i4c_hold_action_request maker-checker requests to release a hold, or to freeze / unfreeze debits on the account
|
||||
m_i4c_outbox responses waiting to be (re)sent to I4C
|
||||
m_i4c_api_log every call in either direction, for the performance review the RBI letter asks for
|
||||
m_i4c_bank_master the specification's Bank Code Master (seeded by 0611)
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0610-1-i4c-tables">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_i4c_request"/></not>
|
||||
</preConditions>
|
||||
|
||||
<createTable tableName="m_i4c_access_token">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="token_hash" type="VARCHAR(64)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_i4c_access_token_hash"/></column>
|
||||
<column name="client_ip" type="VARCHAR(64)"/>
|
||||
<column name="issued_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="expires_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="revoked" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
|
||||
<createTable tableName="m_i4c_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="acknowledgement_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<column name="job_id" type="VARCHAR(64)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_i4c_request_job_id"/></column>
|
||||
<column name="sub_category" type="VARCHAR(200)"/>
|
||||
<column name="requestor" type="VARCHAR(50)"/>
|
||||
<column name="payer_bank" type="VARCHAR(200)"/>
|
||||
<column name="payer_bank_code" type="VARCHAR(10)"/>
|
||||
<column name="mode_of_payment" type="VARCHAR(30)"/>
|
||||
<column name="payer_mobile_number" type="VARCHAR(20)"/>
|
||||
<column name="payer_account_number" type="VARCHAR(40)"/>
|
||||
<column name="state" type="VARCHAR(100)"/>
|
||||
<column name="district" type="VARCHAR(100)"/>
|
||||
<column name="transaction_type" type="VARCHAR(50)"/>
|
||||
<column name="wallet" type="VARCHAR(100)"/>
|
||||
<column name="max_layer" type="INT"/>
|
||||
<column name="payload_hash" type="VARCHAR(64)"><constraints nullable="false"/></column>
|
||||
<column name="raw_payload" type="TEXT"/>
|
||||
<column name="source_ip" type="VARCHAR(64)"/>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="processing_attempts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="processing_error" type="TEXT"/>
|
||||
<column name="received_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="processed_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_request" indexName="idx_i4c_request_ack">
|
||||
<column name="acknowledgement_no"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_i4c_request" indexName="idx_i4c_request_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_incident">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="request_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_incident_request" references="m_i4c_request(id)"/></column>
|
||||
<column name="seq_no" type="INT"><constraints nullable="false"/></column>
|
||||
<column name="layer" type="INT"><constraints nullable="false"/></column>
|
||||
<column name="amount" type="DECIMAL(19,2)"/>
|
||||
<column name="disputed_amount" type="DECIMAL(19,2)"/>
|
||||
<column name="rrn" type="VARCHAR(64)"/>
|
||||
<column name="transaction_date" type="DATE"/>
|
||||
<column name="transaction_time" type="VARCHAR(12)"/>
|
||||
<column name="card_first6" type="VARCHAR(6)"/>
|
||||
<column name="card_last4" type="VARCHAR(4)"/>
|
||||
<column name="card_length" type="VARCHAR(4)"/>
|
||||
<column name="account_type" type="VARCHAR(10)"/>
|
||||
<column name="account_id" type="BIGINT"/>
|
||||
<column name="account_no" type="VARCHAR(40)"/>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<column name="matched_txn_id" type="BIGINT"/>
|
||||
<column name="match_method" type="VARCHAR(30)"/>
|
||||
<column name="outcome" type="VARCHAR(30)"/>
|
||||
<column name="status_code" type="VARCHAR(4)"/>
|
||||
<column name="remarks" type="VARCHAR(500)"/>
|
||||
<column name="processed_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_incident" indexName="idx_i4c_incident_request">
|
||||
<column name="request_id"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_finding">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="request_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_finding_request" references="m_i4c_request(id)"/></column>
|
||||
<column name="incident_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_finding_incident" references="m_i4c_incident(id)"/></column>
|
||||
<column name="txn_type_id" type="INT"><constraints nullable="false"/></column>
|
||||
<column name="txn_type" type="VARCHAR(60)"><constraints nullable="false"/></column>
|
||||
<column name="amount" type="DECIMAL(19,2)"/>
|
||||
<column name="disputed_amount" type="DECIMAL(19,2)"/>
|
||||
<column name="status_code" type="VARCHAR(4)"/>
|
||||
<column name="entry_json" type="TEXT"><constraints nullable="false"/></column>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_finding" indexName="idx_i4c_finding_request">
|
||||
<column name="request_id"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_hold">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="request_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_hold_request" references="m_i4c_request(id)"/></column>
|
||||
<column name="incident_id" type="BIGINT"><constraints nullable="false" unique="true" uniqueConstraintName="uq_i4c_hold_incident" foreignKeyName="fk_i4c_hold_incident" references="m_i4c_incident(id)"/></column>
|
||||
<column name="acknowledgement_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<column name="account_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<column name="account_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<column name="layer" type="INT"><constraints nullable="false"/></column>
|
||||
<column name="disputed_amount" type="DECIMAL(19,2)"><constraints nullable="false"/></column>
|
||||
<column name="amount_held" type="DECIMAL(19,2)" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="shortfall" type="DECIMAL(19,2)" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="debit_frozen" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="held_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="review_due_on" type="DATE"><constraints nullable="false"/></column>
|
||||
<column name="intimation_due_by" type="TIMESTAMP"/>
|
||||
<column name="customer_intimated_at" type="TIMESTAMP"/>
|
||||
<column name="intimation_mode" type="VARCHAR(30)"/>
|
||||
<column name="intimation_reference" type="VARCHAR(200)"/>
|
||||
<column name="intimated_by" type="VARCHAR(100)"/>
|
||||
<column name="last_topup_at" type="TIMESTAMP"/>
|
||||
<column name="released_at" type="TIMESTAMP"/>
|
||||
<column name="release_reason_type" type="VARCHAR(40)"/>
|
||||
<column name="release_reference" type="VARCHAR(200)"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_hold" indexName="idx_i4c_hold_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_i4c_hold" indexName="idx_i4c_hold_account">
|
||||
<column name="account_type"/>
|
||||
<column name="account_id"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_hold_txn">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="hold_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_hold_txn_hold" references="m_i4c_hold(id)"/></column>
|
||||
<column name="account_txn_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="amount" type="DECIMAL(19,2)"><constraints nullable="false"/></column>
|
||||
<column name="placed_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="released_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_hold_txn" indexName="idx_i4c_hold_txn_hold">
|
||||
<column name="hold_id"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_hold_action_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="hold_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_hold_action_hold" references="m_i4c_hold(id)"/></column>
|
||||
<column name="action" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="reason_type" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<column name="order_reference" type="VARCHAR(200)"/>
|
||||
<column name="order_date" type="DATE"/>
|
||||
<column name="authority" type="VARCHAR(200)"/>
|
||||
<column name="remarks" type="VARCHAR(1000)"/>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="maker_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="maker_name" type="VARCHAR(255)"/>
|
||||
<column name="maker_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="checker_id" type="BIGINT"/>
|
||||
<column name="checker_name" type="VARCHAR(255)"/>
|
||||
<column name="checker_at" type="TIMESTAMP"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
<column name="failure_message" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_hold_action_request" indexName="idx_i4c_hold_action_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_outbox">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="request_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_outbox_request" references="m_i4c_request(id)"/></column>
|
||||
<column name="message_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="payload" type="TEXT"><constraints nullable="false"/></column>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="attempts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="next_attempt_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="last_error" type="TEXT"/>
|
||||
<column name="http_status" type="INT"/>
|
||||
<column name="response_body" type="TEXT"/>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="sent_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_outbox" indexName="idx_i4c_outbox_due">
|
||||
<column name="status"/>
|
||||
<column name="next_attempt_at"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_api_log">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="direction" type="VARCHAR(3)"><constraints nullable="false"/></column>
|
||||
<column name="endpoint" type="VARCHAR(300)"><constraints nullable="false"/></column>
|
||||
<column name="http_status" type="INT"/>
|
||||
<column name="response_code" type="VARCHAR(10)"/>
|
||||
<column name="acknowledgement_no" type="VARCHAR(40)"/>
|
||||
<column name="duration_ms" type="BIGINT"/>
|
||||
<column name="client_ip" type="VARCHAR(64)"/>
|
||||
<column name="request_body" type="TEXT"/>
|
||||
<column name="response_body" type="TEXT"/>
|
||||
<column name="error" type="VARCHAR(1000)"/>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_api_log" indexName="idx_i4c_api_log_created">
|
||||
<column name="created_at"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_bank_master">
|
||||
<column name="bank_code" type="INT"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="bank_name" type="VARCHAR(255)"><constraints nullable="false"/></column>
|
||||
<column name="entity_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<!-- First four letters of the bank's IFSC, so a payee IFSC can be reported with its I4C bank code. -->
|
||||
<column name="ifsc_prefix" type="VARCHAR(4)"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_bank_master" indexName="idx_i4c_bank_master_ifsc">
|
||||
<column name="ifsc_prefix"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!-- Permissions. Only the release / freeze approval is maker-checker, and that is enforced in the handler (the maker
|
||||
cannot approve their own request) rather than through the command queue, the same way cash transfer does it. -->
|
||||
<changeSet author="fineract" id="0610-2-i4c-permissions">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_I4C'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="READ_I4C"/>
|
||||
<column name="entity_name" value="I4C"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="RETRY_I4C"/>
|
||||
<column name="entity_name" value="I4C"/>
|
||||
<column name="action_name" value="RETRY"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="REQUESTACTION_I4CHOLD"/>
|
||||
<column name="entity_name" value="I4CHOLD"/>
|
||||
<column name="action_name" value="REQUESTACTION"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="APPROVEACTION_I4CHOLD"/>
|
||||
<column name="entity_name" value="I4CHOLD"/>
|
||||
<column name="action_name" value="APPROVEACTION"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="INTIMATE_I4CHOLD"/>
|
||||
<column name="entity_name" value="I4CHOLD"/>
|
||||
<column name="action_name" value="INTIMATE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0610-3-i4c-dispatch-job">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'I4C Dispatch'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="job">
|
||||
<column name="name" value="I4C Dispatch"/>
|
||||
<column name="display_name" value="I4C Dispatch"/>
|
||||
<column name="short_name" value="I4CDISP"/>
|
||||
<!-- Every minute: sends responses waiting in m_i4c_outbox, and processes any complaint whose immediate
|
||||
after-commit processing did not finish (server restart, failure). -->
|
||||
<column name="cron_expression" value="0 0/1 * * * ?"/>
|
||||
<column name="create_time" valueDate="${current_datetime}"/>
|
||||
<column name="task_priority" valueNumeric="5"/>
|
||||
<column name="group_name"/>
|
||||
<column name="previous_run_start_time"/>
|
||||
<column name="job_key" value="I4C DispatchJobDetail1 _ DEFAULT"/>
|
||||
<column name="initializing_errorlog"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="currently_running" valueBoolean="false"/>
|
||||
<column name="updates_allowed" valueBoolean="true"/>
|
||||
<column name="scheduler_group" valueNumeric="1"/>
|
||||
<column name="is_misfired" valueBoolean="false"/>
|
||||
<column name="node_id" valueNumeric="1"/>
|
||||
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0610-4-i4c-hold-review-job">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'I4C Hold Review'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="job">
|
||||
<column name="name" value="I4C Hold Review"/>
|
||||
<column name="display_name" value="I4C Hold Review"/>
|
||||
<column name="short_name" value="I4CHREV"/>
|
||||
<!-- Every 15 minutes: tops up prospective holds from new credits (the money that arrives after the
|
||||
complaint is the money that would otherwise walk out). -->
|
||||
<column name="cron_expression" value="0 0/15 * * * ?"/>
|
||||
<column name="create_time" valueDate="${current_datetime}"/>
|
||||
<column name="task_priority" valueNumeric="5"/>
|
||||
<column name="group_name"/>
|
||||
<column name="previous_run_start_time"/>
|
||||
<column name="job_key" value="I4C Hold ReviewJobDetail1 _ DEFAULT"/>
|
||||
<column name="initializing_errorlog"/>
|
||||
<column name="is_active" valueBoolean="true"/>
|
||||
<column name="currently_running" valueBoolean="false"/>
|
||||
<column name="updates_allowed" valueBoolean="true"/>
|
||||
<column name="scheduler_group" valueNumeric="1"/>
|
||||
<column name="is_misfired" valueBoolean="false"/>
|
||||
<column name="node_id" valueNumeric="1"/>
|
||||
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<!--
|
||||
I4C Bank Code Master, as published in the I4C-MHA "Banks API Integration Specification 2.0" (Bank Code Master
|
||||
table). Every bank must maintain it: payee_bank_code / root_bankid in the responses sent to I4C are these codes.
|
||||
Entity types as printed there: BNK bank, WLT wallet, MER merchant, PPI prepaid instrument, CBD, INS.
|
||||
Generated from the specification table; a later revision from I4C is loaded the same way, by a new changeset.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
<changeSet author="fineract" id="0611-1-i4c-bank-master-seed">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_i4c_bank_master</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_i4c_bank_master (bank_code, bank_name, entity_type) VALUES
|
||||
(3, 'Axis Bank', 'BNK'),
|
||||
(4, 'BANK OF BAHRAIN AND KUWAIT B.S.C', 'BNK'),
|
||||
(5, 'Bank of Baroda (Including Vijaya Bank and Dena Bank)', 'BNK'),
|
||||
(7, 'Bank of India', 'BNK'),
|
||||
(8, 'Bank of Maharashtra', 'BNK'),
|
||||
(9, 'Canara Bank (including Syndicate Bank)', 'BNK'),
|
||||
(10, 'Central Bank of India', 'BNK'),
|
||||
(11, 'City Union Bank', 'BNK'),
|
||||
(13, 'Deutsche Bank', 'BNK'),
|
||||
(14, 'DCB Bank (Development Credit Bank)', 'BNK'),
|
||||
(15, 'Dhanlaxmi Bank', 'BNK'),
|
||||
(16, 'Federal Bank', 'BNK'),
|
||||
(17, 'ICICI Bank', 'BNK'),
|
||||
(18, 'IDBI Bank', 'BNK'),
|
||||
(19, 'Indian Bank (including Allahabad Bank)', 'BNK'),
|
||||
(20, 'Indian Overseas Bank', 'BNK'),
|
||||
(21, 'IndusInd Bank', 'BNK'),
|
||||
(23, 'Jammu and Kashmir Bank', 'BNK'),
|
||||
(24, 'Karnataka Bank Ltd', 'BNK'),
|
||||
(25, 'Karur Vysya Bank', 'BNK'),
|
||||
(27, 'DBS Bank (Including Lakshmi Vilas Bank)', 'BNK'),
|
||||
(29, 'Punjab National Bank (including Oriental Bank of Commerce and United Bank of India)', 'BNK'),
|
||||
(31, 'Punjab & Sind Bank', 'BNK'),
|
||||
(33, 'South Indian Bank', 'BNK'),
|
||||
(36, 'State Bank of India', 'BNK'),
|
||||
(41, 'Tamilnad Mercantile Bank Ltd.', 'BNK'),
|
||||
(42, 'UCO Bank', 'BNK'),
|
||||
(43, 'Union Bank of India (including Andhra Bank and Corporation Bank)', 'BNK'),
|
||||
(46, 'Yes Bank', 'BNK'),
|
||||
(52, 'Paytm Payment Bank', 'BNK'),
|
||||
(53, 'Amazon Pay', 'WLT'),
|
||||
(54, 'Google Pay', 'WLT'),
|
||||
(55, 'PhonePe', 'WLT'),
|
||||
(56, 'Mobikwik', 'WLT'),
|
||||
(58, 'Citi MasterPass', 'MER'),
|
||||
(62, 'Others', 'BNK'),
|
||||
(63, 'Flipkart', 'MER'),
|
||||
(64, 'Catholic Syrian Bank LTD. (CSB)', 'BNK'),
|
||||
(65, 'HDFC Bank', 'BNK'),
|
||||
(66, 'IDFC First Bank', 'BNK'),
|
||||
(67, 'Kotak Mahindra Bank', 'BNK'),
|
||||
(68, 'Nainital Bank', 'BNK'),
|
||||
(70, 'Fino Payments Bank', 'BNK'),
|
||||
(74, 'mRupee', 'WLT'),
|
||||
(78, 'Freecharge', 'WLT'),
|
||||
(79, 'ItzCash', 'WLT'),
|
||||
(86, 'Chiller', 'WLT'),
|
||||
(87, 'Amazon India', 'MER'),
|
||||
(88, 'Paytm', 'MER'),
|
||||
(89, 'Snapdeal', 'MER'),
|
||||
(91, 'Zivame', 'MER'),
|
||||
(92, 'Jabong', 'MER'),
|
||||
(93, 'ShopClues', 'MER'),
|
||||
(94, 'Myntra', 'MER'),
|
||||
(96, 'Bigbasket', 'MER'),
|
||||
(97, 'Pepperfry', 'MER'),
|
||||
(98, 'Limeroad', 'MER'),
|
||||
(100, 'Clovia', 'MER'),
|
||||
(102, 'Koovs', 'MER'),
|
||||
(103, 'Yepme', 'MER'),
|
||||
(104, 'Grofers', 'MER'),
|
||||
(105, 'Rediff', 'MER'),
|
||||
(106, 'ZopNow', 'MER'),
|
||||
(107, 'HealthKart', 'MER'),
|
||||
(108, 'Lenskart', 'MER'),
|
||||
(111, 'ITC Portal', 'MER'),
|
||||
(112, 'iBhejo', 'MER'),
|
||||
(114, 'SriSriTattva', 'MER'),
|
||||
(115, 'Croma', 'MER'),
|
||||
(117, 'Naaptol India', 'MER'),
|
||||
(118, 'Natures Basket', 'MER'),
|
||||
(119, 'Giskaa', 'MER'),
|
||||
(120, 'Homeshop18', 'MER'),
|
||||
(121, 'Future Bazaar', 'MER'),
|
||||
(122, 'Yebhi', 'MER'),
|
||||
(124, 'Fabindia', 'MER'),
|
||||
(125, 'The North East Store', 'MER'),
|
||||
(126, 'Archies Online', 'MER'),
|
||||
(127, 'Khadi Natural', 'MER'),
|
||||
(129, 'Just Herbs', 'MER'),
|
||||
(130, 'Shahnaz Husain Group', 'MER'),
|
||||
(131, 'Brindco', 'MER'),
|
||||
(132, 'Flavours Of My City', 'MER'),
|
||||
(133, 'Firstcry', 'MER'),
|
||||
(134, 'Netmeds', 'MER'),
|
||||
(135, 'Payu', 'WLT'),
|
||||
(136, 'Citi Bank', 'BNK'),
|
||||
(138, 'NSDL Payments Bank Ltd', 'BNK'),
|
||||
(139, 'State Bank of India (Credit card)', 'BNK'),
|
||||
(140, 'Others', 'MER'),
|
||||
(141, 'Airtel Payments Bank', 'BNK'),
|
||||
(142, 'Others', 'WLT'),
|
||||
(143, 'THE GUJARAT STATE COOPERATIVE BANK', 'BNK'),
|
||||
(144, 'Ratnakar Bank Limited (RBL)', 'BNK'),
|
||||
(145, 'Ujjivan Small Finance Bank Ltd', 'BNK'),
|
||||
(146, 'Jio Payments Bank Ltd', 'BNK'),
|
||||
(147, 'Equitas Bank', 'BNK'),
|
||||
(148, 'AU Bank', 'BNK'),
|
||||
(149, 'Razorpay', 'WLT'),
|
||||
(151, 'Standard Chartered Bank', 'BNK'),
|
||||
(152, 'Nobroker Technologies Solution Private Ltd', 'MER'),
|
||||
(153, 'Suryoday Bank', 'BNK'),
|
||||
(154, 'CRED', 'MER'),
|
||||
(155, 'Cashfree', 'MER'),
|
||||
(156, 'India Post Payments Bank', 'BNK'),
|
||||
(157, 'Chhattisgarh Gramin Bank', 'BNK'),
|
||||
(158, 'Billdesk', 'MER'),
|
||||
(159, 'Khata Book', 'MER'),
|
||||
(160, 'Kerala Gramin Bank', 'BNK'),
|
||||
(161, 'Puduvai Bharathiar Grama', 'BNK'),
|
||||
(162, 'Karnataka Gramin Bank', 'BNK'),
|
||||
(163, 'Meghalaya Rural Bank', 'BNK'),
|
||||
(164, 'Vidharbha Konkan Gramin Bank', 'BNK'),
|
||||
(165, 'Tamil Nadu Grama Bank', 'BNK'),
|
||||
(166, 'HP Gramin Bank', 'BNK'),
|
||||
(167, 'Baroda U.P. Bank', 'BNK'),
|
||||
(168, 'Baroda Gujarat Gramin Bank', 'BNK'),
|
||||
(169, 'Telangana Grameena Bank (including DECCAN GRAMIN BANK)', 'BNK'),
|
||||
(170, 'Andhra Pragathi Grameena Bank', 'BNK'),
|
||||
(171, 'Andhra Pradesh Grameena Vikas Bank', 'BNK'),
|
||||
(172, 'Bharat Pe', 'MER'),
|
||||
(173, 'Dhani Pay', 'WLT'),
|
||||
(174, 'Ease Buzz', 'WLT'),
|
||||
(175, 'State Bank Of Mauritius (SBM)', 'BNK'),
|
||||
(176, 'Paytm', 'WLT'),
|
||||
(177, 'Fincare Small Finance Bank', 'BNK'),
|
||||
(178, 'POS ATM PURCH', 'WLT'),
|
||||
(179, 'Bandhan Bank', 'BNK'),
|
||||
(180, 'ESAF Small Finance Bank', 'BNK'),
|
||||
(181, 'ATOM', 'MER'),
|
||||
(182, 'MagicBricks', 'MER'),
|
||||
(183, 'Paulpay', 'MER'),
|
||||
(184, 'Utkarsh Small Finance Bank Limited', 'BNK'),
|
||||
(185, 'Bharat Co-operative Bank (M) Ltd', 'BNK'),
|
||||
(186, 'American Express', 'BNK'),
|
||||
(187, 'The Kangra Central Co-operative Bank Ltd', 'BNK'),
|
||||
(188, 'Baroda Rajasthan Kshetriya Gramin Bank', 'BNK'),
|
||||
(189, 'Midas Corporate Park', 'BNK'),
|
||||
(190, 'Maharashtra Gramin Bank', 'BNK'),
|
||||
(191, 'The Nawanagar Co-operative Bank Ltd', 'BNK'),
|
||||
(192, 'The Gandhidham Mercantile Co-Op Bank Ltd', 'BNK'),
|
||||
(193, 'Dharti Bank', 'BNK'),
|
||||
(194, 'Uttarakhand Gramin Bank (including Uttaranchal Gramin Bank)', 'BNK'),
|
||||
(195, 'The Zoroastrian Co-operative Bank Ltd.', 'BNK'),
|
||||
(196, 'Saurashtra Gramin Bank', 'BNK'),
|
||||
(197, 'Navnirman Bank Bhavan', 'BNK'),
|
||||
(198, 'Adarsh Co-operative Bank Ltd', 'BNK'),
|
||||
(199, 'Sarva Haryana Gramin Bank (including Haryana Gramin Bank)', 'BNK'),
|
||||
(200, 'THE BHAGYODAYA CO-OPERATIVE BANK', 'BNK'),
|
||||
(201, 'The Mehsana Urban Co-Op. Bank', 'BNK'),
|
||||
(202, 'Rajkot Peoples Co-Operative Bank Ltd', 'BNK'),
|
||||
(203, 'The Anand Mercantile Co-Op.Bank Ltd', 'BNK'),
|
||||
(204, 'Rajkot Nagrik Sahakari Bank', 'BNK'),
|
||||
(205, 'The Karnavati Co-Op. Bank Ltd', 'BNK'),
|
||||
(206, 'Rajasthan Marudhara Gramin Bank', 'BNK'),
|
||||
(207, 'The Veraval Peoples Co Op Bank Ltd', 'BNK'),
|
||||
(208, 'Bank of Baroda (Credit card)', 'BNK'),
|
||||
(209, 'Qwikcilver Solutions', 'MER'),
|
||||
(210, 'Saraswat Co-operative Bank Ltd.', 'BNK'),
|
||||
(211, 'Jana Small Finance Bank - Jana Bank', 'BNK'),
|
||||
(212, 'The Bhavnagar District Co-operative Bank Ltd.', 'BNK'),
|
||||
(213, 'THE BHARUCH DISTRICT CENTRAL CO OPERATIVE BANK LTD.', 'BNK'),
|
||||
(214, 'The Banaskantha District Central Co-operative Bank Ltd.', 'BNK'),
|
||||
(215, 'VALSAD DISTRICT CENTRAL CO. OP. BANK LTD.', 'BNK'),
|
||||
(216, 'Dakshin Bihar Gramin Bank', 'BNK'),
|
||||
(217, 'THE AHMEDABAD DISTRICT CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(218, 'Citizens Co operative Bank Ltd.', 'BNK'),
|
||||
(219, 'The Rajkot Commercial Co-operative Bank Ltd.', 'BNK'),
|
||||
(220, 'Darussalam Co-operative Urban Bank Ltd', 'BNK'),
|
||||
(221, 'Sterling Urban Co-op Bank Ltd', 'BNK'),
|
||||
(222, 'The Kalupur Commercial Co-op. Bank Ltd', 'BNK'),
|
||||
(223, 'The Navnirman co op Bank Ltd.', 'BNK'),
|
||||
(224, 'SBPP Co-operative Bank Ltd.', 'BNK'),
|
||||
(225, 'Manappuram Finance Ltd.', 'BNK'),
|
||||
(226, 'PRIME CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(227, 'Shivalik Small Finance Bank.', 'BNK'),
|
||||
(228, 'The Sutex Co-operative Bank Ltd.', 'BNK'),
|
||||
(229, 'Amreli Jilla Madhyastha Sahakari Bank Ltd.', 'BNK'),
|
||||
(230, 'Sarvodaya commercial co-operative Bank Limited.', 'BNK'),
|
||||
(231, 'KANKARIA MANINAGAR NAGRIK SAHAKARI BANK LTD.', 'BNK'),
|
||||
(232, 'Gujarat Ambuja Co-Op Bank Ltd.', 'BNK'),
|
||||
(233, 'Union Co-operative Bank Ltd.', 'BNK'),
|
||||
(234, 'Deogiri Nagari Sahakari Bank Ltd. Aurangabad.', 'BNK'),
|
||||
(236, 'Capital Small Finance Bank Limited', 'BNK'),
|
||||
(237, 'Nutan Nagarik Sahakari Bank Ltd.', 'BNK'),
|
||||
(238, 'The Sarvodaya Sahakari Bank Ltd. modasa', 'BNK'),
|
||||
(239, 'Akhand Anand Co. Op. Bank Ltd.', 'BNK'),
|
||||
(240, 'THE MAHUDHA NAGRIK SAHAKARI BANK LTD.', 'BNK'),
|
||||
(241, 'THE VARACHHA CO.OP.BANK LTD.', 'BNK'),
|
||||
(242, 'Bhatpara Naihati Co-Operative Bank Ltd.', 'BNK'),
|
||||
(243, 'Shri Rajkot District Co. Operative Bank Ltd.', 'BNK'),
|
||||
(244, 'MODEL COOPERATIVE BANK LTD', 'BNK'),
|
||||
(245, 'NKGSB CO-OPERATIVE BANK LTD', 'BNK'),
|
||||
(246, 'The Financial Co-operative bank ltd', 'BNK'),
|
||||
(247, 'The Co-Operative Bank Of Rajkot ltd', 'BNK'),
|
||||
(248, 'THE VEPAR UDHYOG VIKAS SAHAKARI BANK LTD', 'BNK'),
|
||||
(249, 'The Jalgaon Peoples Co-op. Bank Ltd', 'BNK'),
|
||||
(250, 'Saraspur Nagarik Co.Operative Bank Ltd', 'BNK'),
|
||||
(251, 'UMA COOPERATIVE BANK LTD', 'BNK'),
|
||||
(252, 'Punjab Gramin Bank', 'BNK'),
|
||||
(253, 'Patan Nagarik Sahakari Bank Ltd', 'BNK'),
|
||||
(254, 'GP Parsik Sahakari Bank Ltd', 'BNK'),
|
||||
(255, 'BASSEIN CATHOLIC CO-OP BANK LTD', 'BNK'),
|
||||
(256, 'THE GODHRA URBAN CO-OP BANK LTD', 'BNK'),
|
||||
(257, 'Vasai Vikas Sahakari Bank Ltd', 'BNK'),
|
||||
(258, 'The Greater Bombay Co-operative Bank Limited', 'BNK'),
|
||||
(259, 'THE PANCHMAHAL DIST COOP BANK LTD', 'BNK'),
|
||||
(260, 'Barclays Bank PLC', 'BNK'),
|
||||
(261, 'The Junagadh Jilla Sahakari Bank Ltd', 'BNK'),
|
||||
(262, 'The Dahod Urban Co. Op. bank Ltd.', 'BNK'),
|
||||
(263, 'The Surat Peoples Co-operative Bank Ltd.', 'BNK'),
|
||||
(264, 'Shree Mahuva Nagrik Sahakari Bank Ltd.', 'BNK'),
|
||||
(265, 'HSBC Bank.', 'BNK'),
|
||||
(266, 'The Gandevi People''s Co-Op.Bank Ltd.', 'BNK'),
|
||||
(267, 'Bajaj Finance Ltd.', 'WLT'),
|
||||
(268, 'Spice Money.', 'WLT'),
|
||||
(269, 'Kallappanna Awade Ichalkaranji Janata Sahakari Bank Ltd', 'BNK'),
|
||||
(271, 'Nykaa', 'MER'),
|
||||
(272, 'Housing.com', 'MER'),
|
||||
(273, 'Pay1 (Mindsarray Network PVT LTD)', 'MER'),
|
||||
(274, 'SVC Co-operative Bank Limited', 'BNK'),
|
||||
(275, 'Dhanera Mercantile Co Op Bank Ltd', 'BNK'),
|
||||
(276, 'THE JAMNAGAR MAHILA SAHAKARI BANK LTD', 'BNK'),
|
||||
(277, 'SHREE KADI NAGARIK SAHAKARI BANK LTD', 'BNK'),
|
||||
(278, 'THE CO-OPERATIVE BANK OF MEHSANA LTD', 'BNK'),
|
||||
(280, 'THE KUKARWADA NAGARIK SAHAKARI BANK LTD', 'BNK'),
|
||||
(281, 'SURAT NATIONAL CO-OP. BANK LTD.', 'BNK'),
|
||||
(282, 'THE BAVLA NAGRIK SAHAKARI BANK LTD.', 'BNK'),
|
||||
(283, 'THE MEHSANA DISTRICT CENTRAL CO-OP BANK LTD', 'BNK'),
|
||||
(284, 'THE MALPUR NAGARIK SAHAKARI BANK LTD', 'BNK'),
|
||||
(285, 'SHREE BOTAD MERCANTILE COOP BANK LTD', 'BNK'),
|
||||
(286, 'THE BOTAD PEOPLES CO OPERATIVE BANK LTD', 'BNK'),
|
||||
(287, 'M S CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(288, 'THE SURAT DISTRICT CO. OP. BANK LTD.', 'BNK'),
|
||||
(289, 'PRAGATI SAHAKARI BANK LTD.', 'BNK'),
|
||||
(290, 'SABARKANTHA DISTRICT COOPERATIVE BANK LTD.', 'BNK'),
|
||||
(291, 'THE BHUJ COMMERCIAL CO OPERATIVE BANK LTD.', 'BNK'),
|
||||
(292, 'JIVAN COMMERCIAL CO OPERATIVE BANK LTD.', 'BNK'),
|
||||
(293, 'THE BARDOLI NAGRIK SAHAKARI BANK LTD.', 'BNK'),
|
||||
(294, 'THE UDAIPUR MAHILA SAMRIDHI URBAN CO OPERATIVE BANK LTD.', 'BNK'),
|
||||
(295, 'APNA SAHAKARI BANK LTD.', 'BNK'),
|
||||
(296, 'SHRI MAHILA SEWA SAHAKARI BANK LTD.', 'BNK'),
|
||||
(297, 'THE VERAVAL MERCANTILE CO-OP BANK LTD.', 'BNK'),
|
||||
(298, 'THE SABARKANTHA DISTRICT CENTRAL COOPERATIVE BANK LTD.', 'BNK'),
|
||||
(299, 'HIMATNAGAR NAGRIK SAHAKARI BANK LTD', 'BNK'),
|
||||
(300, 'BHILWARA URBAN COOPERATIVE BANK LTD', 'BNK'),
|
||||
(301, 'PORBANDAR COMMERCIAL CO OPERATIVE BANK LTD', 'BNK'),
|
||||
(302, 'BHAVNAGAR MAHILA NAGRIK SAHAKARI BANK LTD', 'BNK'),
|
||||
(303, 'THE KAIRA DISTRICT CENTRAL CO-OPERATIVE BANK LTD', 'BNK'),
|
||||
(304, 'THE MODASA NAGARIK SAHAKARI BANK LTD', 'BNK'),
|
||||
(305, 'SANMITRA MAHILA NAGARI SAHAKARI BANK MARYADIT, CHANDRAPUR', 'BNK'),
|
||||
(306, 'ANDAMAN & NICOBAR STATE COOPERATIVE BANK', 'BNK'),
|
||||
(307, 'BAJAJ ALLIANZ GENERAL INSURANCE', 'INS'),
|
||||
(308, 'MAX LIFE INSURANCE', 'INS'),
|
||||
(309, 'THE AKOLA JANATA COMM. CO-OP BANK LTD', 'BNK'),
|
||||
(310, 'INDRAPRASTHA SEHKARI BANK LTD', 'BNK'),
|
||||
(311, 'MARATHA CO-OPERATIVE BANK LTD', 'BNK'),
|
||||
(312, 'PALI URBAN CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(313, 'SARDAR SINGH NAGRIK SAHKARI BANK MARYADIT', 'BNK'),
|
||||
(314, 'TAMIL NADU STATE APEX COOP BANK', 'BNK'),
|
||||
(315, 'NEW INDIA CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(316, 'RELIANCE PAYMENT SOLUTIONS LIMITED', 'MER'),
|
||||
(317, 'TIRUNELVELI DISTRICT CENTRAL COOPERATIVE BANK LTD', 'BNK'),
|
||||
(318, 'SHREE MAHAYOGI LAKSHMAMMA CO-OPERATIVE BANK LTD', 'BNK'),
|
||||
(319, 'MAHESH SAHAKARI BANK LTD.', 'BNK'),
|
||||
(320, 'BUSINESS CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(321, 'LIFE INSURANCE CORPORATION', 'INS'),
|
||||
(322, 'THE VELLORE DISTRICT CENTRAL COOPERATIVE BANK', 'BNK'),
|
||||
(323, 'VIJAY COMMERCIAL CO. OPERATIVE BANK LTD.', 'BNK'),
|
||||
(324, 'THE SAURASHTRA CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(325, 'BALASINOR NAGARIK SAHAKARI BANK LTD', 'BNK'),
|
||||
(326, 'THE KANCHEEPURAM CENTRAL COOPERATIVE BANK LTD', 'BNK'),
|
||||
(327, 'TRI O TECH SOLUTIONS PRIVATE LIMITED', 'WLT'),
|
||||
(328, 'CHENNAI CENTRAL CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(329, 'THE AHMEDABAD MERCANTILE CO-OPERATIVE. BANK LTD', 'BNK'),
|
||||
(331, 'THE BARODA CENTRAL CO OP BANK LTD', 'BNK'),
|
||||
(332, 'UTTARKASHI ZILA SAHKARI BANK LTD.', 'BNK'),
|
||||
(333, 'SHIVA SAHAKARI BANK NIYAMITA', 'BNK'),
|
||||
(335, 'ASSOCIATE CO OP BANK LTD', 'BNK'),
|
||||
(336, 'POCHAMPALLY CO-OP URBAN BANK LTD', 'BNK'),
|
||||
(337, 'THE MATTANCHERRY SARVAJANIK CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(338, 'GODAVARI URBAN CO. OP. BANK LTD.', 'BNK'),
|
||||
(339, 'OPEN', 'WLT'),
|
||||
(340, 'AIRPAY', 'WLT'),
|
||||
(341, 'COMPUTER AGE MANAGEMENT SERVICES LTD.', 'WLT'),
|
||||
(342, 'PAYSHARP PRIVATE LIMITED', 'WLT'),
|
||||
(343, 'EPAYNOW', 'WLT'),
|
||||
(344, 'MPURSE', 'WLT'),
|
||||
(345, 'TATA PAYMENTS LIMITED', 'MER'),
|
||||
(346, 'THE GANDHIDHAM CO-OPERTIVE BANK LTD', 'BNK'),
|
||||
(347, 'PINE LABS', 'WLT'),
|
||||
(348, 'TELANGANA STATE CO-OP APEX BANK(SPO-IDBI)', 'BNK'),
|
||||
(349, 'TRI O TECH SOLUTIONS PRIVATE LIMITED', 'MER'),
|
||||
(350, 'Prathama UP Gramin Bank', 'BNK'),
|
||||
(351, 'Eroute Technologies Private LIMITED', 'MER'),
|
||||
(352, 'Sumitomo Mitsui Banking Corporation (SMBC)', 'BNK'),
|
||||
(353, 'Giottus', 'WLT'),
|
||||
(354, 'CHATTISGARH GRAMIN BANK)', 'BNK'),
|
||||
(355, 'UNITY SMALL FINANCE BANK', 'BNK'),
|
||||
(356, 'UNOCOIN', 'WLT'),
|
||||
(357, 'ZEBPAY', 'WLT'),
|
||||
(358, 'WAZIRX', 'WLT'),
|
||||
(359, 'BITBNS', 'WLT'),
|
||||
(360, 'Madhyanchal Gramin Bank', 'BNK'),
|
||||
(361, 'JANATA SAHAKARI BANK PUNE', 'BNK'),
|
||||
(362, 'Sri Guru Raghavendra Sahakara Bank Niyamitha', 'BNK'),
|
||||
(363, 'Navi Mumbai Co-Operative Bank Limited', 'BNK'),
|
||||
(364, 'The Akola District Central Co-op. Bank Ltd', 'BNK'),
|
||||
(365, 'The Chandigarh State Cooperative Bank Ltd.', 'BNK'),
|
||||
(366, 'COINDCX', 'WLT'),
|
||||
(367, 'BUYUCOIN', 'WLT'),
|
||||
(368, 'THE COSMOS CO-OP. BANK LTD.', 'BNK'),
|
||||
(369, 'JAMMU and KASHMIR Grameen bank', 'BNK'),
|
||||
(370, 'Mangalore catholic Co Operative bank Ltd (M.C.C)', 'BNK'),
|
||||
(372, 'TJSB Sahakari Bank Limited', 'BNK'),
|
||||
(373, 'Jharkhand Rajya Gramin Bank', 'BNK'),
|
||||
(374, 'THE KHAMGAON URBAN COOP BANK LTD KHAMGAON', 'BNK'),
|
||||
(375, 'The Baramati Sahakari Bank Limited', 'BNK'),
|
||||
(376, 'RESILIENT INNOVATIONS PVT LTD', 'MER'),
|
||||
(377, 'OPEN FINANCIAL TECHNOLOGY', 'MER'),
|
||||
(378, 'DREAMPLUG PAYTECH SOLUTIONS PVT LTD', 'MER'),
|
||||
(379, 'Shri Shivayogi Murughendra Swami Urban Co-Op. Bank Ltd', 'BNK'),
|
||||
(380, 'Sri Chatrapati Shivaji Maharaj Sahakari Bank Niyamith', 'BNK'),
|
||||
(381, 'SAPTAGIRI GRAMEENA BANK', 'BNK'),
|
||||
(382, 'Thane Bharat Sahakari Bank Ltd', 'BNK'),
|
||||
(383, 'Suvarnayug Sahakari Bank Ltd.', 'BNK'),
|
||||
(384, 'Contai Co-operative Bank Ltd', 'BNK'),
|
||||
(385, 'Madhya Bihar Gramin Bank', 'BNK'),
|
||||
(386, 'MAHAVEER CO-OPERATIVE BANK', 'BNK'),
|
||||
(387, 'The Suvikas Peoples Co Operative Bank Ltd', 'BNK'),
|
||||
(388, 'Coastal Local Area Bank', 'BNK'),
|
||||
(389, 'The Himachal Pradesh State Co-operative Bank Ltd', 'BNK'),
|
||||
(390, 'Associate Co-Operative Bank Ltd', 'BNK'),
|
||||
(391, 'Samarth Sahakari Bank (Solapur)', 'BNK'),
|
||||
(392, 'PRAGATHI KRISHNA GRAMIN BANK(DIRECT)', 'BNK'),
|
||||
(393, 'The Gayatri Co-operative Urban Bank Ltd', 'BNK'),
|
||||
(394, 'Himachal Pradesh Gramin Bank', 'BNK'),
|
||||
(395, 'AMBARNATH JAIHIND CO OP BANK', 'BNK'),
|
||||
(396, 'MIZORAM RURAL BANK', 'BNK'),
|
||||
(397, 'Aryavart Bank', 'BNK'),
|
||||
(398, 'KODINAR TALUKA CO-OP. BANKING UNION LTD.', 'BNK'),
|
||||
(399, 'TRANSCORP INTERNATIONAL LIMITED', 'WLT'),
|
||||
(400, 'Pittoragarh Zila Sahakari Bank Ltd', 'BNK'),
|
||||
(401, 'The Adarsh Co-operative Urban Bank Ltd.', 'BNK'),
|
||||
(402, 'ADARSH MAHILA NAGARI SAHAKARI BANK LTD AURANGABAD', 'BNK'),
|
||||
(403, 'PRIYADARSHANI NAGARI SAHAKARI BANK MARYADIT JALNA', 'BNK'),
|
||||
(404, 'VISHWESHAR SAHKARI BANK LTD PUNE', 'BNK'),
|
||||
(405, 'Rajadhani Co-op. Urban Bank Ltd', 'BNK'),
|
||||
(406, 'THE GADCHIROLI DISTRICT CENTRAL COOP BANK LTD', 'BNK'),
|
||||
(407, 'DOMBIVILI NAGARIK SAHAKARI BANK', 'BNK'),
|
||||
(408, 'TRANSERV PRIVATE LIMITED-Prepaid Bank-PPI', 'MER'),
|
||||
(409, 'SAMRUDHI CO-OP BANK', 'BNK'),
|
||||
(410, 'The Tirupati Urban Co-operative Bank Ltd.', 'BNK'),
|
||||
(411, 'THE AHMEDNAGAR DISTRICT CENTRAL CO-OPERATIVE BANK LTD', 'BNK'),
|
||||
(412, 'The Vijay Co-operative Bank Ltd.', 'BNK'),
|
||||
(413, 'Janaseva Sahakari Bank ltd,Pune', 'BNK'),
|
||||
(414, 'JANASEVA SAHAKARI BANK BORIVALI LTD', 'BNK'),
|
||||
(415, 'THE SARANGPUR CO OP BANK LTD', 'BNK'),
|
||||
(416, 'KERALA STATE CO-OPERATIVE BANK', 'BNK'),
|
||||
(417, 'BHARAT PETROLEUM CORPORATION LIMITED', 'MER'),
|
||||
(418, 'THE BARODA CITY CO. OPERATIVE BANK LTD', 'BNK'),
|
||||
(419, 'INFIBEAM AVENUES LIMITED (CCAVENUE)', 'WLT'),
|
||||
(420, 'THE VAISH COOPERATIVE ADARSH BANK LTD.', 'BNK'),
|
||||
(421, 'Abhyudaya Co-operative Bank', 'BNK'),
|
||||
(422, 'The Agrasen Co-Operative Urban Bank Limited', 'BNK'),
|
||||
(423, 'Amreli Nagrik Sahakari Bank Ltd.', 'BNK'),
|
||||
(424, 'AMRELI JILLA MADHYASTHA SAHKARI BANK.', 'BNK'),
|
||||
(425, 'The Andhra Pradesh Mahesh Co-operative Urban Bank Ltd', 'BNK'),
|
||||
(426, 'Arvind Sahakari Bank Ltd.', 'BNK'),
|
||||
(427, 'The Bundi Urban Co-operative Bank Ltd', 'BNK'),
|
||||
(428, 'Baran Nagarik Sahakari Bank Ltd.', 'BNK'),
|
||||
(429, 'CHAITANYA GODAVARI GRAMEENA BANK', 'BNK'),
|
||||
(430, 'CITIBANK CORPORATE', 'BNK'),
|
||||
(431, 'Shri Chhatrapati Rajarshi Shahu Urban Co-op.Bank Ltd', 'BNK'),
|
||||
(432, 'Garagepreneurs Internet Private Limited', 'MER'),
|
||||
(433, 'GS Mahanagar Coop bank', 'BNK'),
|
||||
(434, 'The Andhra Pradesh State Co-operative Bank Ltd', 'BNK'),
|
||||
(435, 'The Kanakamahalakshmi Co-operative Bank Ltd', 'BNK'),
|
||||
(436, 'The Kangra Co-operative Bank', 'BNK'),
|
||||
(437, 'Kolhapur Dist. Central Co-Op. Bank Ltd.', 'BNK'),
|
||||
(438, 'Kalyan Janata Sahakari Bank', 'BNK'),
|
||||
(439, 'Kolhapur Urban Co-operative Bank', 'BNK'),
|
||||
(440, 'MANIPUR RURAL BANK', 'BNK'),
|
||||
(441, 'MALVIYA URBAN COOPERATIVE BANK LTD', 'BNK'),
|
||||
(442, 'Mizoram Urban Co-Operative Development Bank Ltd', 'BNK'),
|
||||
(443, 'NORTH EAST SMALL FINANCE BANK', 'BNK'),
|
||||
(444, 'THE NASIK MERCHANTS CO OPERATIVE BANK LTD NASHIK', 'BNK'),
|
||||
(445, 'PASCHIM BANGA GRAMIN BANK', 'BNK'),
|
||||
(446, 'Pune Peoples Co-Op. Bank', 'BNK'),
|
||||
(447, 'RAJARSHI SHAHU SAHAKARI BANK LTD', 'BNK'),
|
||||
(448, 'SUCO SOUHARDA SAHAKARI BANK LTD', 'BNK'),
|
||||
(449, 'SUNDARLAL SAWJI BANK LTD', 'BNK'),
|
||||
(450, 'SHREE WARANA SAHAKARIK BANK LTD WARANANAGAR', 'BNK'),
|
||||
(451, 'The Tirur Urban Cooperative Bank Ltd', 'BNK'),
|
||||
(452, 'Uttar Pradesh Co-operative Bank Ltd.', 'BNK'),
|
||||
(453, 'The Visakhapatnam Cooperative Bank Ltd.', 'BNK'),
|
||||
(454, 'BALANCEHERO INDIA PRIVATE LIMITED', 'WLT'),
|
||||
(455, 'Assam Gramin Vikash Bank', 'BNK'),
|
||||
(456, 'THE VAISH COOPERATIVE NEW BANK LTD', 'BNK'),
|
||||
(457, 'JPMORGAN', 'BNK'),
|
||||
(458, 'COINSWITCH', 'WLT'),
|
||||
(459, 'Bank of America', 'BNK'),
|
||||
(460, 'Textile Traders Co-operative Bank LTD', 'BNK'),
|
||||
(461, 'Vallabh Vidyanagar Commercial CO-OPERATIVE BANK Ltd.', 'BNK'),
|
||||
(462, 'Gondal Nagrik Sahkari Bank Ltd.', 'BNK'),
|
||||
(463, 'The Chhapi Nagrik Sahakari Bank Ltd', 'BNK'),
|
||||
(464, 'The Social Co-operative Bank Ltd.', 'BNK'),
|
||||
(465, 'Ghoghamba Vibhag Nagrik Sahakari Bank Ltd', 'BNK'),
|
||||
(466, 'Unjha Nagarik Sahakari Bank Ltd', 'BNK'),
|
||||
(467, 'The Godhra City Co-operative Bank Ltd', 'BNK'),
|
||||
(468, 'Shree Bharat Co-operative Bank Ltd', 'BNK'),
|
||||
(469, 'Gujarat Mercantile Co-operative Bank Ltd', 'BNK'),
|
||||
(470, 'Navsarjan Industrial Co-operative Bank Ltd.', 'BNK'),
|
||||
(471, 'Amarnath Co-operative Bank Ltd', 'BNK'),
|
||||
(472, 'Progressive Mercantile Co-op Bank Ltd.', 'BNK'),
|
||||
(473, 'SARVODAY NAGARIK SAHAKARI BANK LTD.,HIMMATNAGAR', 'BNK'),
|
||||
(474, 'THE KARAD URBAN CO-OP. BANK LTD.', 'BNK'),
|
||||
(475, 'SARDAR VALLABHBHAI SAHAKARI BANK LTD', 'BNK'),
|
||||
(476, 'Ypay Card', 'WLT'),
|
||||
(477, 'THE MUNICIPAL CO-OP BANK LTD, MUMBAI.', 'BNK'),
|
||||
(478, 'THE IDAR NAGRIK SAHAKARI BANK LTD', 'BNK'),
|
||||
(479, 'BHOPAL CO-OPERATIVE CENTRAL BANK LTD', 'BNK'),
|
||||
(480, 'Agroha Cooperative urban Bank Limited', 'BNK'),
|
||||
(481, 'AMBAJOGAI PEOPLES CO OP BANK LTD', 'BNK'),
|
||||
(482, 'THE BAGHAT URBAN CO-OP. BANK LTD', 'BNK'),
|
||||
(483, 'BALITIKURI CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(484, 'ADARNIYA P. D. PATILSAHEB SAHAKARI BANK LTD', 'BNK'),
|
||||
(485, 'SHREE VARDHAMAN SAHAKARI BANK LIMITED', 'BNK'),
|
||||
(486, 'AHMEDNAGAR SHAHAR SAHAKARI BANK MARYADIT, AHMEDNAGAR', 'BNK'),
|
||||
(487, 'THE BHAWANIPATNA CENTRAL COOPERATIVE BANK LTD.', 'BNK'),
|
||||
(488, 'RapiPay Fintech Pvt. Ltd', 'WLT'),
|
||||
(489, 'Shree Bhavnagar Nagrik Sahakari Bank Ltd.', 'BNK'),
|
||||
(490, 'THE GOA STATE COOPERATIVE BANK LTD', 'BNK'),
|
||||
(491, 'BHADRADRI CO OPERATIVE URBAN BANK LTD', 'BNK'),
|
||||
(492, 'BALOTRA URBAN CO OPERATIVE BANK LTD.', 'BNK'),
|
||||
(493, 'The Kachchh District Central Co-Operative Bank Ltd', 'BNK'),
|
||||
(494, 'The Banaskantha Mercantile Co. Operative Bank Ltd', 'BNK'),
|
||||
(495, 'The Surat Mercantile Co-op. Bank Ltd.', 'BNK'),
|
||||
(496, 'The Bhuj Mercantile Co-operative Bank Ltd.', 'BNK'),
|
||||
(497, 'The Karnataka State Co-Operative Apex Bank Ltd.', 'BNK'),
|
||||
(498, 'Decfin Tech Private Limited ( Decentro)', 'WLT'),
|
||||
(499, 'Bombay Mercantile Co-operative Bank Ltd.', 'BNK'),
|
||||
(500, 'The Nilambur Co operative Urban Bank Ltd.', 'BNK'),
|
||||
(501, '1Pay Mobileware Private Ltd.', 'WLT'),
|
||||
(502, 'The Maharashtra State Co-op. Bank Ltd.', 'BNK'),
|
||||
(503, 'KEB Hana Bank Ltd.', 'BNK'),
|
||||
(504, 'THE SARVODAYA SAHAKARI BANK LTD.', 'BNK'),
|
||||
(505, 'THE WEST BENGAL STATE CO. OPERATIVE BANK', 'BNK'),
|
||||
(506, 'LivQuik Technology (lndia) Pvt. Ltd', 'WLT'),
|
||||
(507, 'I Money Pay Pvt. Ltd.(EMeditek Global Pvt. Ltd.)', 'WLT'),
|
||||
(508, 'Manjeri Co-Operative Urban Bank Ltd', 'BNK'),
|
||||
(509, 'The Wardha District Central Co-operative Bank Ltd.', 'BNK'),
|
||||
(510, 'Western Union Financial Services', 'WLT'),
|
||||
(511, 'THE DAMAN AND DIU STATE CO-OPERATIVE BANK LTD.', 'BNK'),
|
||||
(512, 'Jogindra Central Cooperative Bank Ltd', 'BNK'),
|
||||
(513, 'Slice ( GaragePreneurs Internet Pvt Ltd )', 'WLT'),
|
||||
(514, 'Tripura Gramin Bank', 'BNK'),
|
||||
(515, 'Jamia Co-operative Bank', 'BNK'),
|
||||
(516, 'PPIW0881822 One Mobikwik Systems Pvt Ltd.', 'PPI'),
|
||||
(517, 'PPIW0882027 PhonePe Pvt. Ltd', 'PPI'),
|
||||
(518, 'PPIW0882111 Spice Money Pvt Ltd.', 'PPI'),
|
||||
(519, 'PPIW0884005 IDFC First Bank Pvt Ltd.', 'PPI'),
|
||||
(520, 'PPIW0884102 Livquik Technology Pvt. Ltd.', 'PPI'),
|
||||
(521, 'PPIW0886232 Garagepreneur Internet Pvt ltd.', 'PPI'),
|
||||
(522, 'ICIC0PABCDE ICICI Bank', 'PPI'),
|
||||
(523, 'PPIW0884308 Hip Bar Private Limited (CRED)', 'PPI'),
|
||||
(524, 'PPIW0884207 Amazon Pay (India) Pvt. Ltd.', 'PPI'),
|
||||
(525, 'PPIW0884713 Pine Labs Private Ltd.', 'PPI'),
|
||||
(526, 'PPIW0884915 Eroute Technologies Pvt. Ltd.', 'PPI'),
|
||||
(527, 'PPIW0885526 Axis Bank Freecharge', 'PPI'),
|
||||
(528, 'YESB0PPIUPI Yes Bank Ltd', 'PPI'),
|
||||
(529, 'State Bank of India (CBDC)', 'CBD'),
|
||||
(530, 'ICICI Bank (CBDC)', 'CBD'),
|
||||
(531, 'IDFC First Bank (CBDC)', 'CBD'),
|
||||
(532, 'Yes Bank (CBDC)', 'CBD'),
|
||||
(533, 'HDFC Bank (CBDC)', 'CBD'),
|
||||
(534, 'Union Bank of India (CBDC)', 'CBD'),
|
||||
(535, 'Bank of Baroda (CBDC)', 'CBD'),
|
||||
(536, 'Kotak Mahindra Bank (CBDC)', 'CBD'),
|
||||
(537, 'Canara Bank (CBDC)', 'CBD'),
|
||||
(538, 'Axis Bank (CBDC)', 'CBD'),
|
||||
(539, 'IndusInd Bank (CBDC)', 'CBD'),
|
||||
(540, 'Punjab National Bank (CBDC)', 'CBD'),
|
||||
(541, 'Federal Bank (CBDC)', 'CBD'),
|
||||
(542, 'Karnataka Bank Ltd (CBDC)', 'CBD'),
|
||||
(543, 'Indian Bank (CBDC)', 'CBD'),
|
||||
(544, 'IDBI Bank (CBDC)', 'CBD'),
|
||||
(545, 'PayPoint India Network Pvt. Ltd.', 'WLT'),
|
||||
(546, 'Krumvruddhi Technologies Pvt. Ltd.', 'MER'),
|
||||
(547, 'NATIONAL BANK FOR AGRICULTURE AND RURAL DEVELOPMENT (NABARD)', 'BNK'),
|
||||
(548, 'The Assam Co-Operative Apex Bank Ltd. (APEX BANK)', 'BNK');
|
||||
</sql>
|
||||
</changeSet>
|
||||
<!-- IFSC prefixes of the banks whose prefix is certain. A payee IFSC whose prefix is not listed is still reported,
|
||||
with the IFSC itself; only the I4C bank code is then left at 0 and flagged in remarks. -->
|
||||
<changeSet author="fineract" id="0611-2-i4c-bank-master-ifsc-prefix">
|
||||
<sql>
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UTIB' WHERE bank_code = 3;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BBKM' WHERE bank_code = 4;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BARB' WHERE bank_code = 5;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BKID' WHERE bank_code = 7;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'MAHB' WHERE bank_code = 8;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CNRB' WHERE bank_code = 9;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CBIN' WHERE bank_code = 10;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CIUB' WHERE bank_code = 11;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'DEUT' WHERE bank_code = 13;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'DCBL' WHERE bank_code = 14;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'DLXB' WHERE bank_code = 15;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'FDRL' WHERE bank_code = 16;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'ICIC' WHERE bank_code = 17;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IBKL' WHERE bank_code = 18;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IDIB' WHERE bank_code = 19;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IOBA' WHERE bank_code = 20;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'INDB' WHERE bank_code = 21;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'JAKA' WHERE bank_code = 23;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'KARB' WHERE bank_code = 24;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'KVBL' WHERE bank_code = 25;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'DBSS' WHERE bank_code = 27;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'PUNB' WHERE bank_code = 29;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'PSIB' WHERE bank_code = 31;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SIBL' WHERE bank_code = 33;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SBIN' WHERE bank_code = 36;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'TMBL' WHERE bank_code = 41;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UCBA' WHERE bank_code = 42;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UBIN' WHERE bank_code = 43;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'YESB' WHERE bank_code = 46;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'PYTM' WHERE bank_code = 52;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CSBK' WHERE bank_code = 64;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'HDFC' WHERE bank_code = 65;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IDFB' WHERE bank_code = 66;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'KKBK' WHERE bank_code = 67;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'NTBL' WHERE bank_code = 68;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'FINO' WHERE bank_code = 70;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CITI' WHERE bank_code = 136;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'AIRP' WHERE bank_code = 141;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UJVN' WHERE bank_code = 145;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'JIOP' WHERE bank_code = 146;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'ESFB' WHERE bank_code = 147;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SCBL' WHERE bank_code = 151;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SURY' WHERE bank_code = 153;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IPOS' WHERE bank_code = 156;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BDBL' WHERE bank_code = 179;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'ESMF' WHERE bank_code = 180;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UTKS' WHERE bank_code = 184;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SRCB' WHERE bank_code = 210;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'JSFB' WHERE bank_code = 211;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SMCB' WHERE bank_code = 227;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'NKGS' WHERE bank_code = 245;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BACB' WHERE bank_code = 255;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'HSBC' WHERE bank_code = 265;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SVCB' WHERE bank_code = 274;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'JSBP' WHERE bank_code = 361;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'TJSB' WHERE bank_code = 372;
|
||||
UPDATE m_i4c_bank_master SET ifsc_prefix = 'ABHY' WHERE bank_code = 421;
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<!--
|
||||
Automatic customer intimation for I4C holds (RBI draft directions: tell the account holder by the end of the next day).
|
||||
|
||||
m_i4c_intimation_channel which channels send the notice automatically, in what order, with what wording;
|
||||
maintained from the I4C Live > Intimation Settings screen
|
||||
m_i4c_intimation_log every notice sent, failed or recorded by hand, per hold
|
||||
|
||||
Seeded with EMAIL switched on and SMS / WhatsApp off. With no channel switched on (or none that reaches the customer),
|
||||
the hold waits for an officer to contact the customer and record it by hand, as before.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0612-1-i4c-intimation-tables">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_i4c_intimation_channel"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_i4c_intimation_channel">
|
||||
<column name="channel" type="VARCHAR(20)"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="enabled" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="priority" type="INT" defaultValueNumeric="1"><constraints nullable="false"/></column>
|
||||
<column name="subject" type="VARCHAR(300)"/>
|
||||
<column name="body" type="TEXT"><constraints nullable="false"/></column>
|
||||
<column name="updated_by" type="VARCHAR(100)"/>
|
||||
<column name="updated_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
|
||||
<createTable tableName="m_i4c_intimation_log">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="hold_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_intimation_log_hold" references="m_i4c_hold(id)"/></column>
|
||||
<column name="channel" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<!-- AUTO (sent by the system), MANUAL (recorded by an officer), RESEND (an officer asked the system to send) -->
|
||||
<column name="mode" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<!-- SENT, FAILED, SKIPPED_NO_DESTINATION, PROVIDER_NOT_CONFIGURED, RECORDED -->
|
||||
<column name="status" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="destination_masked" type="VARCHAR(120)"/>
|
||||
<column name="provider" type="VARCHAR(30)"/>
|
||||
<column name="provider_reference" type="VARCHAR(200)"/>
|
||||
<column name="failure_reason" type="VARCHAR(1000)"/>
|
||||
<column name="actor" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_intimation_log" indexName="idx_i4c_intimation_log_hold">
|
||||
<column name="hold_id"/>
|
||||
</createIndex>
|
||||
|
||||
<addColumn tableName="m_i4c_hold">
|
||||
<column name="auto_intimation_attempts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0612-2-i4c-intimation-channel-seed">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_i4c_intimation_channel</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_i4c_intimation_channel">
|
||||
<column name="channel" value="EMAIL"/>
|
||||
<column name="enabled" valueBoolean="true"/>
|
||||
<column name="priority" valueNumeric="1"/>
|
||||
<column name="subject" value="Amount on hold in your account {accountNumber} - cyber fraud complaint {ackNo}"/>
|
||||
<column name="body" value="Dear {customerName}, An amount of Rs {amountHeld} has been put on hold in your account {accountNumber} with {bankName}, {branchName}, on {holdDate}. This follows a cyber-fraud complaint (acknowledgement no. {ackNo}) registered on the National Cybercrime Reporting Portal (NCRP / 1930) of the Indian Cyber Crime Coordination Centre (I4C), Ministry of Home Affairs. Only the disputed amount is held; the rest of your balance remains available to you. If you believe this hold is in error, please visit your branch with your KYC documents and the reason for the credit. The branch will raise a grievance on the NCRP Grievance Redressal Module for the Investigating Officer to review. Contact: {grievanceEmail} / {customerCare} This is a system-generated message from {bankName}."/>
|
||||
<column name="updated_by" value="system"/>
|
||||
<column name="updated_at" valueDate="${current_datetime}"/>
|
||||
</insert>
|
||||
<insert tableName="m_i4c_intimation_channel">
|
||||
<column name="channel" value="SMS"/>
|
||||
<column name="enabled" valueBoolean="false"/>
|
||||
<column name="priority" valueNumeric="2"/>
|
||||
<column name="subject"/>
|
||||
<column name="body" value="{bankName}: Rs {amountHeld} put on hold in a/c {accountNumber} on {holdDate} on cyber-fraud complaint {ackNo} (NCRP/I4C). Rest of balance usable. Wrong hold? Visit your branch. {customerCare}"/>
|
||||
<column name="updated_by" value="system"/>
|
||||
<column name="updated_at" valueDate="${current_datetime}"/>
|
||||
</insert>
|
||||
<insert tableName="m_i4c_intimation_channel">
|
||||
<column name="channel" value="WHATSAPP"/>
|
||||
<column name="enabled" valueBoolean="false"/>
|
||||
<column name="priority" valueNumeric="3"/>
|
||||
<column name="subject"/>
|
||||
<column name="body" value="{bankName}: Rs {amountHeld} has been put on hold in your account {accountNumber} on {holdDate} following cyber-fraud complaint {ackNo} (NCRP / I4C). The rest of your balance is usable. If this is in error, please visit your branch. Help: {customerCare}"/>
|
||||
<column name="updated_by" value="system"/>
|
||||
<column name="updated_at" valueDate="${current_datetime}"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0612-3-i4c-configure-permission">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CONFIGURE_I4C'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="CONFIGURE_I4C"/>
|
||||
<column name="entity_name" value="I4C"/>
|
||||
<column name="action_name" value="CONFIGURE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
@@ -0,0 +1,240 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<!--
|
||||
The bank's side of the NCRP Grievance Redressal Module (GRM) and Money Restoration Module (MRM). Both are I4C web
|
||||
portals with no API: officers act there, and these tables keep the CBS record of what was raised, decided and
|
||||
carried out on the account, with the deadlines of the GRM manual (IO 7 days, investigation / DGRO / SGRO review 15
|
||||
days each, IO to instruct the bank within 48 hours of a DGRO / SGRO order).
|
||||
|
||||
m_i4c_grievance one GRM grievance on a hold: GR id, enhanced due diligence, stage, deadline, decision
|
||||
m_i4c_grievance_event its history (submitted, VC requested, information sought, decisions, escalations)
|
||||
m_i4c_restoration one MRM restoration: as beneficiary bank (money held here goes back to the victim) or as
|
||||
victim bank (restored money received for our customer); decision, documents, bank action,
|
||||
and the transfer that carried it out
|
||||
m_i4c_document orders, notices, indemnity bonds, FIRs, KYC and statements attached to the above
|
||||
|
||||
Holds can now be released in part (an order names the amount to keep) and restored to the victim; restoration moves
|
||||
the money out of the suspect account in the same transaction as the release, straight to the victim's account here
|
||||
or to the bank's restoration pool account for an outward NEFT / IMPS / RTGS.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0613-1-i4c-hold-amounts">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_i4c_hold" columnName="released_amount"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_i4c_hold">
|
||||
<column name="released_amount" type="DECIMAL(19,2)" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="restored_amount" type="DECIMAL(19,2)" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
<addColumn tableName="m_i4c_hold_action_request">
|
||||
<!-- RELEASE: amount to release (null = all). RESTORE: amount to send to the victim. -->
|
||||
<column name="amount" type="DECIMAL(19,2)"/>
|
||||
<column name="restoration_id" type="BIGINT"/>
|
||||
<column name="grievance_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0613-2-i4c-grievance">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_i4c_grievance"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_i4c_grievance">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="hold_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_grievance_hold" references="m_i4c_hold(id)"/></column>
|
||||
<!-- GR id the GRM portal gives, e.g. GR003260316001; empty while in draft -->
|
||||
<column name="grievance_no" type="VARCHAR(40)"/>
|
||||
<!-- REMOVE_HOLD, UNFREEZE, RESTORE_DIGITAL_SERVICES -->
|
||||
<column name="customer_request" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<column name="customer_reason" type="VARCHAR(2000)"/>
|
||||
<column name="edd_done" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="edd_kyc_verified" type="BOOLEAN"/>
|
||||
<column name="edd_source_of_funds" type="VARCHAR(2000)"/>
|
||||
<column name="edd_customer_statement" type="VARCHAR(2000)"/>
|
||||
<!-- LOW, MEDIUM, HIGH -->
|
||||
<column name="edd_risk" type="VARCHAR(10)"/>
|
||||
<!-- SUPPORT (bank backs the request), NOT_SUPPORT -->
|
||||
<column name="edd_recommendation" type="VARCHAR(20)"/>
|
||||
<column name="edd_by" type="VARCHAR(100)"/>
|
||||
<column name="edd_at" type="TIMESTAMP"/>
|
||||
<!-- DRAFT, NODAL_REVIEW, WITH_IO, IO_INVESTIGATION, WITH_DGRO, WITH_SGRO, CLOSED -->
|
||||
<column name="stage" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<!-- OPEN, ALLOWED, REJECTED, WITHDRAWN, COURT -->
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="stage_entered_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="due_at" type="TIMESTAMP"/>
|
||||
<!-- REMOVE_HOLD, UNFREEZE_HOLD_CONTINUES, UNFREEZE_AND_REMOVE_HOLD, PARTIAL_HOLD, REJECTED_CONTINUE, SEEK_INFO -->
|
||||
<column name="decision" type="VARCHAR(30)"/>
|
||||
<!-- IO, DGRO, SGRO -->
|
||||
<column name="decision_level" type="VARCHAR(10)"/>
|
||||
<column name="decision_keep_amount" type="DECIMAL(19,2)"/>
|
||||
<column name="decision_order_ref" type="VARCHAR(200)"/>
|
||||
<column name="decision_date" type="DATE"/>
|
||||
<column name="decision_remarks" type="VARCHAR(2000)"/>
|
||||
<column name="compliance_due_at" type="TIMESTAMP"/>
|
||||
<column name="action_request_id" type="BIGINT"/>
|
||||
<column name="created_by" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="updated_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_grievance" indexName="idx_i4c_grievance_hold">
|
||||
<column name="hold_id"/>
|
||||
</createIndex>
|
||||
|
||||
<createTable tableName="m_i4c_grievance_event">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="grievance_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_grievance_event" references="m_i4c_grievance(id)"/></column>
|
||||
<column name="event_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="stage" type="VARCHAR(20)"/>
|
||||
<column name="remarks" type="VARCHAR(2000)"/>
|
||||
<column name="actor" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_grievance_event" indexName="idx_i4c_grievance_event">
|
||||
<column name="grievance_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0613-3-i4c-restoration">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_i4c_restoration"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_i4c_restoration">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<!-- BENEFICIARY_BANK (money held here goes back), VICTIM_BANK (restored money comes to our customer) -->
|
||||
<column name="role" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="hold_id" type="BIGINT"><constraints foreignKeyName="fk_i4c_restoration_hold" references="m_i4c_hold(id)"/></column>
|
||||
<column name="request_id" type="BIGINT"/>
|
||||
<column name="acknowledgement_no" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<!-- MR id the MRM portal gives, e.g. MR20251208000003 -->
|
||||
<column name="mrm_request_no" type="VARCHAR(40)"/>
|
||||
<!-- ATTRIBUTION, PRO_RATA, NOT_POSSIBLE_ALREADY_REFUNDED, NOT_POSSIBLE_NO_BALANCE, NOT_POSSIBLE_OTHERS -->
|
||||
<column name="basis" type="VARCHAR(40)"/>
|
||||
<column name="restorable_amount" type="DECIMAL(19,2)"/>
|
||||
<column name="basis_remarks" type="VARCHAR(1000)"/>
|
||||
<column name="victim_name" type="VARCHAR(200)"/>
|
||||
<column name="victim_account_no" type="VARCHAR(40)"/>
|
||||
<column name="victim_ifsc" type="VARCHAR(11)"/>
|
||||
<column name="victim_bank_name" type="VARCHAR(200)"/>
|
||||
<column name="fir_no" type="VARCHAR(100)"/>
|
||||
<column name="fir_date" type="DATE"/>
|
||||
<column name="court_order_no" type="VARCHAR(100)"/>
|
||||
<column name="court_order_date" type="DATE"/>
|
||||
<column name="court_name" type="VARCHAR(200)"/>
|
||||
<column name="bnss_notice_no" type="VARCHAR(100)"/>
|
||||
<column name="bnss_notice_date" type="DATE"/>
|
||||
<column name="indemnity_bond_received" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="io_name" type="VARCHAR(200)"/>
|
||||
<column name="io_police_station" type="VARCHAR(200)"/>
|
||||
<!-- PENDING, COMPLIED, NOT_COMPLIED, REFER_BACK_LEA, REFER_BACK_VICTIM_BANK, CONSENSUS -->
|
||||
<column name="bank_action" type="VARCHAR(30)" defaultValue="PENDING"><constraints nullable="false"/></column>
|
||||
<!-- ISSUE_COURT_ORDER, ISSUE_FIR, ISSUE_ORDER_AND_FIR, ISSUE_INDEMNITY_BOND, or free text for the other actions -->
|
||||
<column name="bank_action_reason" type="VARCHAR(1000)"/>
|
||||
<!--
|
||||
ASSESSED (restorable amount worked out), REQUESTED (MRM request received), AWAITING_APPROVAL (RESTORE raised),
|
||||
IN_POOL (moved to the restoration pool, outward not yet sent or failed), TRANSFER_PENDING (outward sent,
|
||||
awaiting fund-transfer approval or settlement), RESTORED, CLOSED; for VICTIM_BANK: AWAITED, RECEIVED, CLOSED
|
||||
-->
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="action_request_id" type="BIGINT"/>
|
||||
<column name="pool_transfer_id" type="BIGINT"/>
|
||||
<column name="internal_transfer_id" type="BIGINT"/>
|
||||
<column name="fund_transfer_ref" type="VARCHAR(40)"/>
|
||||
<column name="transfer_channel" type="VARCHAR(10)"/>
|
||||
<column name="transfer_status" type="VARCHAR(20)"/>
|
||||
<column name="transfer_error" type="VARCHAR(1000)"/>
|
||||
<column name="utr" type="VARCHAR(40)"/>
|
||||
<column name="restored_amount" type="DECIMAL(19,2)"/>
|
||||
<column name="restored_at" type="TIMESTAMP"/>
|
||||
<column name="mrm_updated" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="mrm_updated_by" type="VARCHAR(100)"/>
|
||||
<column name="mrm_updated_at" type="TIMESTAMP"/>
|
||||
<!-- VICTIM_BANK: money received for our customer, or why none is expected -->
|
||||
<column name="received_amount" type="DECIMAL(19,2)"/>
|
||||
<column name="received_utr" type="VARCHAR(40)"/>
|
||||
<column name="received_on" type="DATE"/>
|
||||
<column name="received_account_no" type="VARCHAR(40)"/>
|
||||
<!-- ALREADY_REFUNDED_BANK, ALREADY_REFUNDED_POLICE_NOTICE, ALREADY_REFUNDED_COURT_ORDER, DECLINED_REFUND,
|
||||
DECLINED_TO_PURSUE, RECEIVED_THROUGH_COURT, NOT_REACHABLE -->
|
||||
<column name="victim_status" type="VARCHAR(40)"/>
|
||||
<column name="victim_status_remarks" type="VARCHAR(1000)"/>
|
||||
<column name="created_by" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="updated_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_restoration" indexName="idx_i4c_restoration_hold">
|
||||
<column name="hold_id"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_i4c_restoration" indexName="idx_i4c_restoration_ack">
|
||||
<column name="acknowledgement_no"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0613-4-i4c-document">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_i4c_document"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_i4c_document">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<!-- HOLD, GRIEVANCE, RESTORATION -->
|
||||
<column name="owner_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="owner_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<!-- IO_ORDER, COURT_ORDER, GRM_ORDER, BNSS_106_NOTICE, INDEMNITY_BOND, FIR, KYC, APPLICATION, PHOTO,
|
||||
BANK_STATEMENT, EDD_REPORT, PAN, OTHER -->
|
||||
<column name="doc_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="file_name" type="VARCHAR(255)"><constraints nullable="false"/></column>
|
||||
<column name="content_type" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||
<column name="size_bytes" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="sha256" type="VARCHAR(64)"><constraints nullable="false"/></column>
|
||||
<column name="content" type="BYTEA"><constraints nullable="false"/></column>
|
||||
<column name="remarks" type="VARCHAR(500)"/>
|
||||
<column name="uploaded_by" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||
<column name="uploaded_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_document" indexName="idx_i4c_document_owner">
|
||||
<column name="owner_type"/>
|
||||
<column name="owner_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0613-5-i4c-grm-mrm-permissions">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code IN ('MANAGE_I4CGRIEVANCE', 'MANAGE_I4CRESTORATION')</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="MANAGE_I4CGRIEVANCE"/>
|
||||
<column name="entity_name" value="I4CGRIEVANCE"/>
|
||||
<column name="action_name" value="MANAGE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="MANAGE_I4CRESTORATION"/>
|
||||
<column name="entity_name" value="I4CRESTORATION"/>
|
||||
<column name="action_name" value="MANAGE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
|
||||
-->
|
||||
<!--
|
||||
1. Outward fund-transfer ledger. Outward NEFT / IMPS / RTGS used to park the payer's money on a customer savings
|
||||
account (FUNDTRANSFER_SUSPENSE) and never settle it, so money sent to other banks stayed in customer deposits.
|
||||
With the GL roles below mapped (Organisation > Clearing GL Configuration) an outward transfer posts:
|
||||
initiate Dr payer Savings Control Cr FUNDTRANSFER_OUTWARD_SUSPENSE
|
||||
success Dr FUNDTRANSFER_OUTWARD_SUSPENSE Cr FUNDTRANSFER_SETTLEMENT (balance with sponsor bank / RBI)
|
||||
fail Dr FUNDTRANSFER_OUTWARD_SUSPENSE Cr payer Savings Control (payer credited back)
|
||||
recall Dr FUNDTRANSFER_SETTLEMENT Cr payer Savings Control (after settlement)
|
||||
Unmapped, the old savings-suspense behaviour continues, so nothing changes until the bank maps them.
|
||||
A transfer can also be sourced from a GL (I4C restoration payable) instead of a customer account.
|
||||
|
||||
2. I4C restoration through a payable GL instead of a pool savings account:
|
||||
approval Dr suspect's Savings / Current Control Cr I4C_RESTORATION_PAYABLE
|
||||
outward Dr I4C_RESTORATION_PAYABLE Cr FUNDTRANSFER_OUTWARD_SUSPENSE ... as above
|
||||
|
||||
3. MHA SOP (02.01.2026) items the CBS did not track: the 15-day credit to the victim after the IO's notice, the
|
||||
suspect's 15-day challenge after a refund, the 90-day hold lapse counted from the grievance with the LEA told
|
||||
15 days before, suspension of digital channels on repeatedly reported accounts, holds on loans / dues repaid
|
||||
with fraud money, a mule-account register, fraud reporting (police, RBI FMR, FIU-IND STR), and the victim bank's
|
||||
duties under RBI's customer-liability directions (shadow credit within 10 working days, 90-day resolution).
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0614-1-fund-transfer-ledger">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_fund_transfer" columnName="park_mode"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_fund_transfer">
|
||||
<!-- ACCOUNT (legacy savings suspense), GL (payer debited to the outward suspense GL), GL_SOURCE (from a GL) -->
|
||||
<column name="park_mode" type="VARCHAR(12)"/>
|
||||
<column name="source_gl_account_id" type="BIGINT"/>
|
||||
<column name="source_office_id" type="BIGINT"/>
|
||||
<column name="source_reference" type="VARCHAR(64)"/>
|
||||
<column name="park_journal_ref" type="VARCHAR(64)"/>
|
||||
<column name="settlement_journal_ref" type="VARCHAR(64)"/>
|
||||
<column name="reversal_journal_ref" type="VARCHAR(64)"/>
|
||||
</addColumn>
|
||||
<!-- A GL-sourced transfer has no payer account or client. -->
|
||||
<dropNotNullConstraint tableName="m_fund_transfer" columnName="payer_account_id" columnDataType="BIGINT"/>
|
||||
<dropNotNullConstraint tableName="m_fund_transfer" columnName="client_id" columnDataType="BIGINT"/>
|
||||
<sql>
|
||||
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
|
||||
SELECT 'FUNDTRANSFER_OUTWARD_SUSPENSE', 'Outward NEFT / IMPS / RTGS awaiting settlement (liability)', NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'FUNDTRANSFER_OUTWARD_SUSPENSE');
|
||||
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
|
||||
SELECT 'FUNDTRANSFER_SETTLEMENT', 'Settlement account for outward transfers: balance with sponsor bank / RBI (asset)', NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'FUNDTRANSFER_SETTLEMENT');
|
||||
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
|
||||
SELECT 'I4C_RESTORATION_PAYABLE', 'I4C / MRM money restored from suspect accounts, payable to victims (sundry liability)', NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'I4C_RESTORATION_PAYABLE');
|
||||
INSERT INTO m_clearing_gl_config (config_key, description, gl_account_id)
|
||||
SELECT 'I4C_SHADOW_CREDIT', 'Shadow credits to customers on reported unauthorised transactions (receivable)', NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_clearing_gl_config WHERE config_key = 'I4C_SHADOW_CREDIT');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0614-2-i4c-sop-columns">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_i4c_restoration" columnName="compliance_due_on"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_i4c_restoration">
|
||||
<!-- SOP process 1: the bank credits the victim within 15 days of the IO's notice / order -->
|
||||
<column name="notice_received_on" type="DATE"/>
|
||||
<column name="compliance_due_on" type="DATE"/>
|
||||
<!-- SOP: the suspect may contest within 15 days of the refund -->
|
||||
<column name="challenge_until" type="DATE"/>
|
||||
<column name="contested" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="contested_on" type="DATE"/>
|
||||
<column name="contest_remarks" type="VARCHAR(1000)"/>
|
||||
<column name="payable_journal_ref" type="VARCHAR(64)"/>
|
||||
<column name="debit_txn_id" type="BIGINT"/>
|
||||
<!-- Victim bank (RBI customer-liability directions, 06.07.2017) -->
|
||||
<column name="txn_date" type="DATE"/>
|
||||
<column name="customer_reported_on" type="DATE"/>
|
||||
<!-- ZERO_BANK_FAULT, ZERO_THIRD_PARTY, LIMITED, CUSTOMER_NEGLIGENCE -->
|
||||
<column name="liability_category" type="VARCHAR(30)"/>
|
||||
<column name="customer_liability" type="DECIMAL(19,2)"/>
|
||||
<column name="shadow_credit_due_on" type="DATE"/>
|
||||
<column name="resolution_due_on" type="DATE"/>
|
||||
<column name="shadow_credit_amount" type="DECIMAL(19,2)"/>
|
||||
<column name="shadow_credit_requested_by" type="VARCHAR(100)"/>
|
||||
<column name="shadow_credit_requested_by_id" type="BIGINT"/>
|
||||
<column name="shadow_credit_status" type="VARCHAR(20)"/>
|
||||
<column name="shadow_credit_txn_id" type="BIGINT"/>
|
||||
<column name="shadow_credit_journal_ref" type="VARCHAR(64)"/>
|
||||
<column name="shadow_credited_by" type="VARCHAR(100)"/>
|
||||
<column name="shadow_credited_at" type="TIMESTAMP"/>
|
||||
<column name="regularised_amount" type="DECIMAL(19,2)"/>
|
||||
<column name="regularise_journal_ref" type="VARCHAR(64)"/>
|
||||
<column name="regularised_at" type="TIMESTAMP"/>
|
||||
</addColumn>
|
||||
<addColumn tableName="m_i4c_hold">
|
||||
<!-- SOP 90-day rule: from the bank's grievance; the LEA is told 15 days before -->
|
||||
<column name="lapse_on" type="DATE"/>
|
||||
<column name="lea_notice_due_on" type="DATE"/>
|
||||
<column name="lea_notice_sent_on" type="DATE"/>
|
||||
<column name="lea_notice_ref" type="VARCHAR(200)"/>
|
||||
<column name="extension_until" type="DATE"/>
|
||||
<column name="extension_ref" type="VARCHAR(200)"/>
|
||||
<column name="digital_suspended" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0614-3-i4c-risk-tables">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_i4c_channel_block"/></not>
|
||||
</preConditions>
|
||||
<!-- Digital channels suspended on a repeatedly reported account (enforced on CBS fund transfers). -->
|
||||
<createTable tableName="m_i4c_channel_block">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="hold_id" type="BIGINT"/>
|
||||
<column name="account_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<column name="account_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<!-- comma list of INTERNAL, IMPS, NEFT, RTGS, UPI, ATM, CARD, AEPS, NETBANKING -->
|
||||
<column name="channels" type="VARCHAR(200)"><constraints nullable="false"/></column>
|
||||
<column name="reason" type="VARCHAR(500)"/>
|
||||
<column name="status" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<column name="placed_by" type="VARCHAR(100)"/>
|
||||
<column name="placed_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="lifted_by" type="VARCHAR(100)"/>
|
||||
<column name="lifted_at" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_channel_block" indexName="idx_i4c_channel_block_account">
|
||||
<column name="account_type"/>
|
||||
<column name="account_id"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- Mule-account register: accounts / customers used to move fraud money. -->
|
||||
<createTable tableName="m_i4c_mule_flag">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="account_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<column name="account_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<!-- AUTO_REPEAT (reported in several complaints), OFFICER, LEA -->
|
||||
<column name="source" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="reports_count" type="INT"/>
|
||||
<column name="reason" type="VARCHAR(1000)"/>
|
||||
<!-- SUSPECTED, CONFIRMED, CLEARED -->
|
||||
<column name="status" type="VARCHAR(12)"><constraints nullable="false"/></column>
|
||||
<column name="flagged_by" type="VARCHAR(100)"/>
|
||||
<column name="flagged_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="reviewed_by" type="VARCHAR(100)"/>
|
||||
<column name="reviewed_at" type="TIMESTAMP"/>
|
||||
<column name="review_remarks" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_mule_flag" indexName="idx_i4c_mule_flag_client">
|
||||
<column name="client_id"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- Fraud reporting: police / LEA, RBI fraud monitoring return, FIU-IND suspicious transaction report. -->
|
||||
<createTable tableName="m_i4c_fraud_report">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="hold_id" type="BIGINT"/>
|
||||
<column name="mule_flag_id" type="BIGINT"/>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<column name="acknowledgement_nos" type="VARCHAR(1000)"/>
|
||||
<column name="amount" type="DECIMAL(19,2)"/>
|
||||
<column name="classified_on" type="DATE"><constraints nullable="false"/></column>
|
||||
<column name="fmr_due_on" type="DATE"/>
|
||||
<column name="fmr_ref" type="VARCHAR(100)"/>
|
||||
<column name="fmr_filed_on" type="DATE"/>
|
||||
<column name="str_due_on" type="DATE"/>
|
||||
<column name="str_ref" type="VARCHAR(100)"/>
|
||||
<column name="str_filed_on" type="DATE"/>
|
||||
<column name="police_ref" type="VARCHAR(200)"/>
|
||||
<column name="police_reported_on" type="DATE"/>
|
||||
<column name="remarks" type="VARCHAR(2000)"/>
|
||||
<column name="status" type="VARCHAR(12)"><constraints nullable="false"/></column>
|
||||
<column name="created_by" type="VARCHAR(100)"/>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="updated_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
|
||||
<!-- Fraud money that repaid a loan / dues here: the SOP asks for the amount to be held and linked accounts told. -->
|
||||
<createTable tableName="m_i4c_linked_alert">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="hold_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<!-- LOAN_REPAYMENT, INTERNAL_TRANSFER -->
|
||||
<column name="kind" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="target_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<column name="target_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="target_no" type="VARCHAR(40)"/>
|
||||
<column name="amount" type="DECIMAL(19,2)"><constraints nullable="false"/></column>
|
||||
<column name="transfer_id" type="BIGINT"/>
|
||||
<column name="moved_on" type="DATE"/>
|
||||
<!-- OPEN, ACTIONED, DISMISSED -->
|
||||
<column name="status" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<column name="remarks" type="VARCHAR(1000)"/>
|
||||
<column name="actioned_by" type="VARCHAR(100)"/>
|
||||
<column name="actioned_at" type="TIMESTAMP"/>
|
||||
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_i4c_linked_alert" indexName="idx_i4c_linked_alert_hold">
|
||||
<column name="hold_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="0614-4-i4c-risk-permission">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'MANAGE_I4CRISK'</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="MANAGE_I4CRISK"/>
|
||||
<column name="entity_name" value="I4CRISK"/>
|
||||
<column name="action_name" value="MANAGE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user