Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae69d126a9 | ||
|
|
e4a2c00d75 | ||
|
|
a8f8aa39b7 |
@@ -1,352 +0,0 @@
|
|||||||
# 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,
|
|
||||||
trace onward debits for any shortfall,
|
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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:** what the balance cannot cover is recorded as a shortfall. The *I4C Hold Review* job tops it up from later 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.pool-account-no` / `FINERACT_I4C_RESTORATION_POOL_ACCOUNT` | Bank-owned savings account (of a bank client) that outward MRM restorations leave from. Blank = only restorations to accounts in this bank. |
|
|
||||||
| `…restoration.fir-threshold` | MRM: above this amount in one complaint the IO must give an FIR. Default 50000. |
|
|
||||||
| `…simulator.enabled` | Local stand-in for I4C. **Never `true` in production.** |
|
|
||||||
|
|
||||||
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 `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`.
|
|
||||||
|
|
||||||
### 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 to the **restoration pool** (IN_POOL) and on by NEFT / IMPS / RTGS through the fund-transfer module (its limits, time window, screening and approval apply). TRANSFER_PENDING becomes RESTORED when the transfer settles (Hold Review job). A failed or refused outward leaves the money in the pool for **Send again**, possibly to a corrected account; it never goes back to the suspect.
|
|
||||||
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 pooled restorations again; mark restorations entered on MRM.
|
|
||||||
- **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 accounts:** the amount hold works, but the CBS's current-account debit block does not reach the account, so a full debit freeze is offered on savings accounts only.
|
|
||||||
- **Current-account transfer legs** are not linked transaction-to-transaction, so onward tracing from a current account is limited to counter withdrawals.
|
|
||||||
- **Payee bank name:** only IFSC prefixes in the seeded list map to an I4C bank code. Others are reported with code 0 and the IFSC in full.
|
|
||||||
- **GRM and MRM** are I4C portal modules with no published API. Officers act there and record it here; nothing is sent to the portals automatically.
|
|
||||||
- **Outward restorations** go only from a savings pool account (the fund-transfer module debits savings accounts only); the pool must be configured, and its client's transfer limits must allow the amounts.
|
|
||||||
- **Current accounts** cannot be debit-frozen (above); GRM *unfreeze* orders apply to savings accounts only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -80,6 +80,7 @@ public class Charge extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "currency_code", length = 3)
|
@Column(name = "currency_code", length = 3)
|
||||||
private String currencyCode;
|
private String currencyCode;
|
||||||
|
|
||||||
|
@Getter
|
||||||
@Column(name = "charge_applies_to_enum", nullable = false)
|
@Column(name = "charge_applies_to_enum", nullable = false)
|
||||||
private Integer chargeAppliesTo;
|
private Integer chargeAppliesTo;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -42,6 +42,8 @@ public enum JobName {
|
|||||||
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
|
||||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||||
|
SEND_KYC_REMINDERS("Send KYC Reminders"), //
|
||||||
|
PROCESS_KYC_NOTICE_QUEUE("Process KYC Notice Queue"), //
|
||||||
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||||
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
||||||
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
|
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
|
||||||
@@ -79,8 +81,6 @@ public enum JobName {
|
|||||||
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
|
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
|
||||||
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
|
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
|
||||||
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
|
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
|
||||||
I4C_DISPATCH("I4C Dispatch"), //
|
|
||||||
I4C_HOLD_REVIEW("I4C Hold Review"), //
|
|
||||||
; //
|
; //
|
||||||
|
|
||||||
private final String name;
|
private final String name;
|
||||||
|
|||||||
+10
-1
@@ -83,15 +83,24 @@ public class SavingsAccountSummaryData {
|
|||||||
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
|
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
|
||||||
*/
|
*/
|
||||||
private final Integer renewalCycleNo;
|
private final Integer renewalCycleNo;
|
||||||
|
/**
|
||||||
|
* The branch this account is held at: the account's own office when it was opened at another branch than the
|
||||||
|
* client's, else the client's (or the group's) office. Lets a customer with accounts at several branches tell
|
||||||
|
* them apart on the client page.
|
||||||
|
*/
|
||||||
|
private final Long officeId;
|
||||||
|
private final String officeName;
|
||||||
|
|
||||||
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
|
||||||
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
|
||||||
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
|
||||||
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
|
||||||
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
|
||||||
final String fdAccountNumber, final Integer renewalCycleNo) {
|
final String fdAccountNumber, final Integer renewalCycleNo, final Long officeId, final String officeName) {
|
||||||
this.fdAccountNumber = fdAccountNumber;
|
this.fdAccountNumber = fdAccountNumber;
|
||||||
this.renewalCycleNo = renewalCycleNo;
|
this.renewalCycleNo = renewalCycleNo;
|
||||||
|
this.officeId = officeId;
|
||||||
|
this.officeName = officeName;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.accountNo = accountNo;
|
this.accountNo = accountNo;
|
||||||
this.externalId = externalId;
|
this.externalId = externalId;
|
||||||
|
|||||||
+5
-1
@@ -21,7 +21,11 @@ package org.apache.fineract.portfolio.aml.domain;
|
|||||||
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
|
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
|
||||||
public enum AmlAppliesTo {
|
public enum AmlAppliesTo {
|
||||||
|
|
||||||
SAVINGS, LOAN, TRANSFER, ALL;
|
/**
|
||||||
|
* CURRENT is a current / Cash Credit account. The engine is not yet fed current-account transactions, so a rule written
|
||||||
|
* for it is catalogued but does not fire until it is.
|
||||||
|
*/
|
||||||
|
SAVINGS, LOAN, TRANSFER, CURRENT, ALL;
|
||||||
|
|
||||||
public boolean matches(AmlTransactionType transactionType) {
|
public boolean matches(AmlTransactionType transactionType) {
|
||||||
return this == ALL || this.name().equals(transactionType.name());
|
return this == ALL || this.name().equals(transactionType.name());
|
||||||
|
|||||||
+2
-12
@@ -20,14 +20,12 @@ package org.apache.fineract.portfolio.aml.domain;
|
|||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
|
||||||
@@ -40,11 +38,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
|
|||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "m_aml_recurring_review")
|
@Table(name = "m_aml_recurring_review")
|
||||||
public class AmlRecurringReview {
|
public class AmlRecurringReview extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(optional = false)
|
@ManyToOne(optional = false)
|
||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
@@ -107,10 +101,6 @@ public class AmlRecurringReview {
|
|||||||
this.alertsActioned = count;
|
this.alertsActioned = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Client getClient() {
|
public Client getClient() {
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -152,6 +152,15 @@ public class AmlTransactionRule {
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows a rule to one direction - CREDIT (money in) or DEBIT (money out), null for either - and, when asked, to cash
|
||||||
|
* only. The engine already honours both; a rule written without them applies to every credit and debit, cash or not.
|
||||||
|
*/
|
||||||
|
public void restrictTo(final String direction, final boolean cashOnly) {
|
||||||
|
this.direction = direction;
|
||||||
|
this.cashOnly = cashOnly;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 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.client.domain;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The comparable fingerprint of an Aadhaar number: the SHA-256 of the 12 digits, as 64 lowercase hex characters.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The hash is computed by the client (browser) and only the hash is sent. The KYC create/update calls go through the
|
||||||
|
* command source, which records the request body in an audit table and in the log, so the 12-digit number must not be
|
||||||
|
* in the body. The server therefore never sees the number and only checks that what it was given has the shape of a
|
||||||
|
* SHA-256 digest.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A plain, unkeyed SHA-256 of a 12-digit number can be reversed by trying every possible number, so this column is a
|
||||||
|
* lookup key, not protection against someone who can read the table.
|
||||||
|
*/
|
||||||
|
public final class AadhaarHash {
|
||||||
|
|
||||||
|
private static final Pattern SHA256_HEX = Pattern.compile("^[0-9a-f]{64}$");
|
||||||
|
|
||||||
|
private AadhaarHash() {}
|
||||||
|
|
||||||
|
/** True when the value has the shape of a lowercase hex SHA-256 digest. */
|
||||||
|
public static boolean isValid(final String value) {
|
||||||
|
return value != null && SHA256_HEX.matcher(value).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value trimmed and lower-cased when it is a well-formed digest; null for blank input. A non-blank value that
|
||||||
|
* is not a digest is rejected rather than silently dropped, so a client bug cannot disable the duplicate check.
|
||||||
|
*/
|
||||||
|
public static String normalizeOrNull(final String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String candidate = raw.trim().toLowerCase();
|
||||||
|
if (!isValid(candidate)) {
|
||||||
|
throw new IllegalArgumentException("aadhaarHash must be a 64-character hex SHA-256 digest");
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -151,6 +151,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "aadhaar_number_masked", length = 12)
|
@Column(name = "aadhaar_number_masked", length = 12)
|
||||||
private String aadhaarNumberMasked;
|
private String aadhaarNumberMasked;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 of the 12-digit Aadhaar, computed client-side (see {@link AadhaarHash}). The masked form cannot tell two
|
||||||
|
* people apart; this can. Null for rows that predate the column and for clients with no Aadhaar.
|
||||||
|
*/
|
||||||
|
@Column(name = "aadhaar_hash", length = 64)
|
||||||
|
private String aadhaarHash;
|
||||||
|
|
||||||
@Column(name = "aadhaar_verified", nullable = false)
|
@Column(name = "aadhaar_verified", nullable = false)
|
||||||
private boolean aadhaarVerified;
|
private boolean aadhaarVerified;
|
||||||
|
|
||||||
@@ -391,6 +398,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
command.stringValueOfParameterNamed("aadhaarNumberMasked"),
|
command.stringValueOfParameterNamed("aadhaarNumberMasked"),
|
||||||
command.stringValueOfParameterNamed("gstinNumber"),
|
command.stringValueOfParameterNamed("gstinNumber"),
|
||||||
command.stringValueOfParameterNamed("remarks"));
|
command.stringValueOfParameterNamed("remarks"));
|
||||||
|
kyc.aadhaarHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed("aadhaarHash"));
|
||||||
kyc.applyExtendedFields(command);
|
kyc.applyExtendedFields(command);
|
||||||
return kyc;
|
return kyc;
|
||||||
}
|
}
|
||||||
@@ -493,6 +501,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
actualChanges.put(aadhaarParam, newValue);
|
actualChanges.put(aadhaarParam, newValue);
|
||||||
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
// The Aadhaar was removed: the fingerprint of the old number must not outlive it.
|
||||||
|
if (this.aadhaarNumberMasked == null) {
|
||||||
|
this.aadhaarHash = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the fingerprint is sent, never the number. Reported as a flag, not a value, because the changes map
|
||||||
|
// is echoed back in the API response.
|
||||||
|
final String aadhaarHashParam = "aadhaarHash";
|
||||||
|
if (command.parameterExists(aadhaarHashParam)) {
|
||||||
|
final String newHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed(aadhaarHashParam));
|
||||||
|
if (newHash != null && !newHash.equals(this.aadhaarHash)) {
|
||||||
|
this.aadhaarHash = newHash;
|
||||||
|
actualChanges.put("aadhaarIdentityUpdated", true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final String gstinParam = "gstinNumber";
|
final String gstinParam = "gstinNumber";
|
||||||
@@ -1050,6 +1073,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return aadhaarNumberMasked;
|
return aadhaarNumberMasked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getAadhaarHash() {
|
||||||
|
return aadhaarHash;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isAadhaarVerified() {
|
public boolean isAadhaarVerified() {
|
||||||
return aadhaarVerified;
|
return aadhaarVerified;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -27,6 +27,13 @@ public interface ClientKycContactLogRepository extends JpaRepository<ClientKycCo
|
|||||||
|
|
||||||
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
||||||
|
|
||||||
|
/** True when a notice carrying this marker has already reached a final outcome on the channel. */
|
||||||
|
boolean existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(Long clientId, KycContactChannel channel,
|
||||||
|
String markerPrefix, java.util.Collection<KycContactDeliveryStatus> statuses);
|
||||||
|
|
||||||
|
/** How many times a marked notice has been tried on the channel — bounds the retries of a failing gateway. */
|
||||||
|
long countByClientIdAndChannelAndNotesStartingWith(Long clientId, KycContactChannel channel, String markerPrefix);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
||||||
* "last contacted" for the whole page without an N+1 query per row.
|
* "last contacted" for the whole page without an N+1 query per row.
|
||||||
|
|||||||
+12
@@ -61,6 +61,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
|||||||
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
||||||
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
|
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KYC records whose re-KYC falls due after {@code asOn} but no later than {@code until}, soonest first.
|
||||||
|
*
|
||||||
|
* <p>The complement of {@link #findLapsedAsOn}: a record is in one list or the other, never both, so the
|
||||||
|
* two together cover everything that needs the branch's attention. Rejected records are excluded for
|
||||||
|
* the same reason as there.
|
||||||
|
*/
|
||||||
|
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate > :asOn "
|
||||||
|
+ "AND k.expiryDate <= :until AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
|
||||||
|
List<ClientKyc> findExpiringBetween(@Param("asOn") LocalDate asOn, @Param("until") LocalDate until,
|
||||||
|
@Param("rejectedStatus") Integer rejectedStatus);
|
||||||
|
|
||||||
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
|
||||||
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
|
||||||
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
|
||||||
|
|||||||
+47
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinColumn;
|
|||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -110,11 +111,57 @@ public class ClientRiskRule {
|
|||||||
@Column(name = "rule_kind", length = 10)
|
@Column(name = "rule_kind", length = 10)
|
||||||
private String ruleKind;
|
private String ruleKind;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this scenario applies to Cash Credit accounts - a current account that is allowed to draw an overdraft
|
||||||
|
* against a sanctioned limit. Lets the Rules Master show, in one place, every scenario that watches a CC account,
|
||||||
|
* whatever its coverage status.
|
||||||
|
*/
|
||||||
|
@Column(name = "cc_relevant", nullable = false)
|
||||||
|
private boolean ccRelevant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rule authored from the Rules Master starts on trial: it is listed first and marked new until an officer marks it
|
||||||
|
* normal. Every rule that was already in the catalog is normal.
|
||||||
|
*/
|
||||||
|
@Column(name = "on_trial", nullable = false)
|
||||||
|
private boolean onTrial;
|
||||||
|
|
||||||
|
@Column(name = "trial_started_on")
|
||||||
|
private LocalDate trialStartedOn;
|
||||||
|
|
||||||
public static final String KIND_KYC = "KYC";
|
public static final String KIND_KYC = "KYC";
|
||||||
public static final String KIND_AML = "AML";
|
public static final String KIND_AML = "AML";
|
||||||
|
|
||||||
protected ClientRiskRule() {}
|
protected ClientRiskRule() {}
|
||||||
|
|
||||||
|
public boolean isCcRelevant() {
|
||||||
|
return ccRelevant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isOnTrial() {
|
||||||
|
return onTrial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getTrialStartedOn() {
|
||||||
|
return trialStartedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts a newly authored rule on trial from {@code startedOn}. */
|
||||||
|
public void startTrial(final LocalDate startedOn) {
|
||||||
|
this.onTrial = true;
|
||||||
|
this.trialStartedOn = startedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the trial: the rule is a normal part of the catalog. */
|
||||||
|
public void markNormal() {
|
||||||
|
this.onTrial = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the scenario as one that watches Cash Credit accounts, so the Rules Master CC chip lists it. */
|
||||||
|
public void markCcRelevant(final boolean ccRelevant) {
|
||||||
|
this.ccRelevant = ccRelevant;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||||
* are attached separately by the caller, which owns the CodeValue lookup.
|
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,8 @@ public interface SearchingClientRepository {
|
|||||||
/**
|
/**
|
||||||
* @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only.
|
* @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only.
|
||||||
* Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY).
|
* Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY).
|
||||||
|
* @param officeId null = no filter; otherwise only clients of that office, still inside officeHierarchy.
|
||||||
*/
|
*/
|
||||||
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
||||||
Integer legalFormValue);
|
Integer legalFormValue, Long officeId);
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-2
@@ -53,7 +53,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
|
||||||
Integer legalFormValue) {
|
Integer legalFormValue, Long officeId) {
|
||||||
/*
|
/*
|
||||||
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
|
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
|
||||||
* this point the upgrade is too costly
|
* this point the upgrade is too costly
|
||||||
@@ -62,6 +62,9 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
|||||||
*/
|
*/
|
||||||
String hierarchyLikeValue = officeHierarchy + "%";
|
String hierarchyLikeValue = officeHierarchy + "%";
|
||||||
|
|
||||||
|
// Customers of other branches that hold an account at the caller's branch are listed here too.
|
||||||
|
final List<Long> linkedClientIds = clientIdsWithAccountAtBranch(hierarchyLikeValue);
|
||||||
|
|
||||||
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
|
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
|
||||||
CriteriaQuery<SearchedClient> query = cb.createQuery(SearchedClient.class);
|
CriteriaQuery<SearchedClient> query = cb.createQuery(SearchedClient.class);
|
||||||
|
|
||||||
@@ -74,7 +77,8 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
|||||||
Join<Client, ClientIdentifier> identity = r.join("identifiers", JoinType.LEFT);
|
Join<Client, ClientIdentifier> identity = r.join("identifiers", JoinType.LEFT);
|
||||||
|
|
||||||
List<Predicate> predicates = new ArrayList<>();
|
List<Predicate> predicates = new ArrayList<>();
|
||||||
predicates.add(cb.like(o.get("hierarchy"), hierarchyLikeValue));
|
final Predicate inHierarchy = cb.like(o.get("hierarchy"), hierarchyLikeValue);
|
||||||
|
predicates.add(linkedClientIds.isEmpty() ? inHierarchy : cb.or(inHierarchy, r.get("id").in(linkedClientIds)));
|
||||||
|
|
||||||
String searchLikeValue = "%" + searchText + "%";
|
String searchLikeValue = "%" + searchText + "%";
|
||||||
predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue),
|
predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue),
|
||||||
@@ -93,6 +97,11 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
|||||||
predicates.add(cb.equal(r.get("legalForm"), legalFormValue));
|
predicates.add(cb.equal(r.get("legalForm"), legalFormValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Branch filter. ANDed with the hierarchy predicate above, so it can only narrow results.
|
||||||
|
if (officeId != null) {
|
||||||
|
predicates.add(cb.equal(o.get("id"), officeId));
|
||||||
|
}
|
||||||
|
|
||||||
return cb.and(predicates.toArray(new Predicate[0]));
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
};
|
};
|
||||||
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
|
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
|
||||||
@@ -108,4 +117,27 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
|
|||||||
|
|
||||||
return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec);
|
return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids of clients holding an account (savings, fixed or recurring deposit, or current) at an office at or below the
|
||||||
|
* given hierarchy pattern, whatever office the client itself belongs to. Only accounts opened at a branch other than
|
||||||
|
* the client's carry their own office, so this is empty for a branch with no such customers, and for Head Office.
|
||||||
|
* Being linked to a branch is deliberately not enough to be listed there.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<Long> clientIdsWithAccountAtBranch(final String hierarchyLikeValue) {
|
||||||
|
final List<Object> rows = entityManager.createNativeQuery(
|
||||||
|
"SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id "
|
||||||
|
+ "WHERE sa.client_id IS NOT NULL AND ao.hierarchy LIKE ?1 "
|
||||||
|
+ "UNION SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id "
|
||||||
|
+ "WHERE ca.client_id IS NOT NULL AND co.hierarchy LIKE ?2")
|
||||||
|
// Positional, not named: EclipseLink passes a native query's ":h" to PostgreSQL as written, which fails
|
||||||
|
// with a syntax error and took the whole client list down with it.
|
||||||
|
.setParameter(1, hierarchyLikeValue).setParameter(2, hierarchyLikeValue).getResultList();
|
||||||
|
final List<Long> ids = new ArrayList<>(rows.size());
|
||||||
|
for (final Object row : rows) {
|
||||||
|
ids.add(((Number) row).longValue());
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public enum NoteType {
|
|||||||
GROUP(600, "noteType.group", "groups", "Group note"), //
|
GROUP(600, "noteType.group", "groups", "Group note"), //
|
||||||
SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), //
|
SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), //
|
||||||
SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction note"), //
|
SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction note"), //
|
||||||
|
CURRENT_ACCOUNT(900, "noteType.currentaccount", "currentaccounts", "Current account note"), //
|
||||||
;
|
;
|
||||||
|
|
||||||
public static final NoteType[] VALUES = values();
|
public static final NoteType[] VALUES = values();
|
||||||
|
|||||||
+12
@@ -28,10 +28,22 @@ public interface CurrentAccountReadPlatformService {
|
|||||||
|
|
||||||
Map<String, Object> retrieveOneTransaction(Long accountId, Long transactionId);
|
Map<String, Object> retrieveOneTransaction(Long accountId, Long transactionId);
|
||||||
|
|
||||||
|
/** The account's charges from m_current_account_charge; chargeStatus is all, active or inactive. */
|
||||||
|
List<Map<String, Object>> retrieveCharges(Long accountId, String chargeStatus);
|
||||||
|
|
||||||
|
Map<String, Object> retrieveCharge(Long accountId, Long accountChargeId);
|
||||||
|
|
||||||
|
/** Template for adding a charge: the active current-account charges in the account's currency. */
|
||||||
|
Map<String, Object> retrieveChargeTemplate(Long accountId);
|
||||||
|
|
||||||
List<Map<String, Object>> retrieveAll();
|
List<Map<String, Object>> retrieveAll();
|
||||||
|
|
||||||
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName);
|
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName);
|
||||||
|
|
||||||
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
||||||
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate);
|
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate);
|
||||||
|
|
||||||
|
/** As above; with {@code overdraftOnly}, only the Cash Credit accounts - those that allow an overdraft. */
|
||||||
|
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
||||||
|
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate, boolean overdraftOnly);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ dependencies {
|
|||||||
'org.apache.avro:avro',
|
'org.apache.avro:avro',
|
||||||
'jakarta.ws.rs:jakarta.ws.rs-api',
|
'jakarta.ws.rs:jakarta.ws.rs-api',
|
||||||
'org.glassfish.jersey.media:jersey-media-multipart',
|
'org.glassfish.jersey.media:jersey-media-multipart',
|
||||||
// Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one.
|
|
||||||
'org.glassfish.jersey.media:jersey-media-sse',
|
|
||||||
|
|
||||||
'org.springframework:spring-jms',
|
'org.springframework:spring-jms',
|
||||||
'org.springframework:spring-context-support',
|
'org.springframework:spring-context-support',
|
||||||
|
|||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 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.accounting.interbranch.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells a person looking at one ledger transaction whether it is one branch's part of a transfer between branches.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A transfer between accounts at different branches is posted as one ledger transaction per branch, joined through the
|
||||||
|
* Liability Transfer account. Looking at either part on its own shows only two ledger lines, with nothing to say it has
|
||||||
|
* another part in another branch. The bank transfer log records the ledger ids of every part, so given any one of them
|
||||||
|
* this reports the whole transfer: who it moved money from and to, in which branches, and who raised and approved it.
|
||||||
|
*/
|
||||||
|
@Path("/v1/accounting/interbranchtransfers")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public class InterBranchTransferApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{transactionId}")
|
||||||
|
public String retrieve(@PathParam("transactionId") final String transactionId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission("JOURNALENTRY");
|
||||||
|
|
||||||
|
// Ledger transaction ids are short alphanumeric strings; anything else cannot be one.
|
||||||
|
if (transactionId == null || !transactionId.matches("[A-Za-z0-9_-]{1,50}")) {
|
||||||
|
return notInterBranch();
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> logs = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT voucher_no, total_amount, gl_txn_id, debit_entries, credit_entries, status FROM m_bank_transfer_log "
|
||||||
|
+ "WHERE (',' || gl_txn_id || ',') LIKE ? ORDER BY id DESC LIMIT 1",
|
||||||
|
"%," + transactionId + ",%");
|
||||||
|
if (logs.isEmpty()) {
|
||||||
|
return notInterBranch();
|
||||||
|
}
|
||||||
|
final Map<String, Object> log = logs.get(0);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> from = legsOf(log.get("debit_entries"));
|
||||||
|
final List<Map<String, Object>> to = legsOf(log.get("credit_entries"));
|
||||||
|
|
||||||
|
final Set<Object> branchIds = new LinkedHashSet<>();
|
||||||
|
for (final Map<String, Object> leg : from) {
|
||||||
|
branchIds.add(leg.get("branchId"));
|
||||||
|
}
|
||||||
|
for (final Map<String, Object> leg : to) {
|
||||||
|
branchIds.add(leg.get("branchId"));
|
||||||
|
}
|
||||||
|
branchIds.remove(null);
|
||||||
|
if (branchIds.size() < 2) {
|
||||||
|
return notInterBranch();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("interBranch", true);
|
||||||
|
out.put("voucherNo", log.get("voucher_no"));
|
||||||
|
out.put("amount", log.get("total_amount"));
|
||||||
|
out.put("status", log.get("status"));
|
||||||
|
out.put("thisTransactionId", transactionId);
|
||||||
|
out.put("from", from);
|
||||||
|
out.put("to", to);
|
||||||
|
out.put("parts", partsOf(String.valueOf(log.get("gl_txn_id"))));
|
||||||
|
|
||||||
|
// Raised and approved by: the clerk's request, when the transfer went through approval.
|
||||||
|
final List<Map<String, Object>> requests = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT clerk_name, manager_name, submitted_at, actioned_at FROM m_clerk_disbursement_request WHERE voucher_no = ? "
|
||||||
|
+ "ORDER BY id DESC LIMIT 1",
|
||||||
|
log.get("voucher_no"));
|
||||||
|
if (!requests.isEmpty()) {
|
||||||
|
final Map<String, Object> request = requests.get(0);
|
||||||
|
out.put("raisedBy", request.get("clerk_name"));
|
||||||
|
out.put("raisedAt", isoOf(request.get("submitted_at")));
|
||||||
|
out.put("approvedBy", request.get("manager_name"));
|
||||||
|
out.put("approvedAt", isoOf(request.get("actioned_at")));
|
||||||
|
}
|
||||||
|
return this.gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String notInterBranch() {
|
||||||
|
return "{\"interBranch\":false}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each account on one side of the transfer, with the branch its books are in. */
|
||||||
|
private List<Map<String, Object>> legsOf(final Object storedJson) {
|
||||||
|
final List<Map<String, Object>> legs = new ArrayList<>();
|
||||||
|
if (storedJson == null) {
|
||||||
|
return legs;
|
||||||
|
}
|
||||||
|
final JsonArray entries = this.gson.fromJson(String.valueOf(storedJson), JsonArray.class);
|
||||||
|
for (final JsonElement element : entries) {
|
||||||
|
final JsonObject entry = element.getAsJsonObject();
|
||||||
|
final Map<String, Object> leg = new LinkedHashMap<>();
|
||||||
|
leg.put("accountNo", text(entry, "accountNo"));
|
||||||
|
leg.put("accountName", text(entry, "accountName"));
|
||||||
|
leg.put("amount", text(entry, "amount"));
|
||||||
|
final Long accountId = entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
|
||||||
|
? entry.get("savingsAccountId").getAsLong()
|
||||||
|
: null;
|
||||||
|
final Map<String, Object> branch = accountId == null ? null : branchOfAccount(text(entry, "accountType"), accountId);
|
||||||
|
leg.put("branchId", branch == null ? null : ((Number) branch.get("id")).longValue());
|
||||||
|
leg.put("branchName", branch == null ? null : branch.get("name"));
|
||||||
|
legs.add(leg);
|
||||||
|
}
|
||||||
|
return legs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry per ledger transaction of the transfer, with the branch it was posted in. */
|
||||||
|
private List<Map<String, Object>> partsOf(final String glTxnIds) {
|
||||||
|
final List<Map<String, Object>> parts = new ArrayList<>();
|
||||||
|
for (final String id : glTxnIds.split(",")) {
|
||||||
|
final String ledgerId = id.trim();
|
||||||
|
if (ledgerId.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final List<Map<String, Object>> offices = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT DISTINCT o.id, o.name FROM acc_gl_journal_entry je JOIN m_office o ON o.id = je.office_id "
|
||||||
|
+ "WHERE je.transaction_id = ?",
|
||||||
|
ledgerId);
|
||||||
|
final Map<String, Object> part = new LinkedHashMap<>();
|
||||||
|
part.put("transactionId", ledgerId);
|
||||||
|
part.put("branchId", offices.isEmpty() ? null : ((Number) offices.get(0).get("id")).longValue());
|
||||||
|
part.put("branchName", offices.isEmpty() ? null : offices.get(0).get("name"));
|
||||||
|
parts.add(part);
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch an account's books are in: its own office when it was opened at another branch than the client's,
|
||||||
|
* otherwise the client's (or group's). A current account is its own table with its own ids.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> branchOfAccount(final String accountType, final Long accountId) {
|
||||||
|
final String sql = "CURRENT".equalsIgnoreCase(accountType)
|
||||||
|
? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id "
|
||||||
|
+ "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?"
|
||||||
|
: "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id "
|
||||||
|
+ "LEFT JOIN m_group g ON g.id = sa.group_id "
|
||||||
|
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
|
||||||
|
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(final JsonObject object, final String name) {
|
||||||
|
final JsonElement element = object.get(name);
|
||||||
|
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String isoOf(final Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value instanceof java.sql.Timestamp) {
|
||||||
|
return ((java.sql.Timestamp) value).toLocalDateTime().toString();
|
||||||
|
}
|
||||||
|
return String.valueOf(value).replace(' ', 'T');
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* 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.accounting.interbranch.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
|
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||||
|
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a branch's cash ledger true when a cashier takes or pays cash for an account held at another branch.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A savings deposit or withdrawal always posts its ledger entries in the branch the account is held at, because that is
|
||||||
|
* where the account's books are. The cash itself, though, is in the drawer of whichever cashier handled it. When that
|
||||||
|
* cashier sits at a different branch, the account's branch would show cash it never received (or paid), and the
|
||||||
|
* cashier's branch would hold cash its ledger does not know about.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This moves the cash leg to the cashier's branch, through the Liability Transfer account, exactly as a transfer
|
||||||
|
* between two branches' accounts is booked: the account's branch reverses the cash line against Liability Transfer, and
|
||||||
|
* the cashier's branch books the same cash line against it. Each branch's books balance on their own and the bank's
|
||||||
|
* total does not change. A cashier at the account's own branch, or a user who is not a cashier, changes nothing.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class InterBranchCashService {
|
||||||
|
|
||||||
|
private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200;
|
||||||
|
private static final int CREDIT = 1;
|
||||||
|
private static final int DEBIT = 2;
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param savingsTransactionId
|
||||||
|
* the deposit or withdrawal just posted
|
||||||
|
* @param accountOfficeId
|
||||||
|
* the branch the account is held at, where that posting was booked
|
||||||
|
* @param deposit
|
||||||
|
* true for cash received, false for cash paid out
|
||||||
|
*/
|
||||||
|
public void moveCashLegToCashierBranch(final Long savingsTransactionId, final Long accountOfficeId, final String accountNo,
|
||||||
|
final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
if (user.getOffice() == null || accountOfficeId == null || savingsTransactionId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isCashier(user.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveCashLegToBranch(savingsTransactionId, accountOfficeId, user.getOffice().getId(), accountNo, amount, transactionDate, deposit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #moveCashLegToCashierBranch}, for a posting made by someone other than the cashier who handled the
|
||||||
|
* cash, such as a manager approving a deposit a cashier took earlier. The branch the cash is in is given.
|
||||||
|
*/
|
||||||
|
public void moveCashLegToBranch(final Long savingsTransactionId, final Long accountOfficeId, final Long cashierOfficeId,
|
||||||
|
final String accountNo, final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
if (cashierOfficeId == null || accountOfficeId == null || savingsTransactionId == null
|
||||||
|
|| cashierOfficeId.equals(accountOfficeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cash line of the posting just made: the debit of a deposit, the credit of a withdrawal.
|
||||||
|
final List<Map<String, Object>> cashLines = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT account_id, currency_code FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ? "
|
||||||
|
+ "AND amount = ? AND reversed = false ORDER BY id DESC",
|
||||||
|
savingsTransactionId, deposit ? DEBIT : CREDIT, amount);
|
||||||
|
if (cashLines.isEmpty()) {
|
||||||
|
log.warn("No cash line found for savings transaction {}; its cash was not moved to the cashier's branch",
|
||||||
|
savingsTransactionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Long cashGlId = ((Number) cashLines.get(0).get("account_id")).longValue();
|
||||||
|
final String currencyCode = String.valueOf(cashLines.get(0).get("currency_code"));
|
||||||
|
final Long suspenseGlId = liabilityTransferGlId();
|
||||||
|
|
||||||
|
final String note = (deposit ? "Inter-branch cash deposit" : "Inter-branch cash withdrawal") + " | A/C " + accountNo
|
||||||
|
+ " | savings txn " + savingsTransactionId + " | cashier " + user.getUsername() + " | via Liability Transfer";
|
||||||
|
|
||||||
|
// Account's branch: take the cash line back out. A deposit's was Dr cash, so Cr cash; a withdrawal's was Cr cash.
|
||||||
|
post(accountOfficeId, cashGlId, deposit ? CREDIT : DEBIT, suspenseGlId, deposit ? DEBIT : CREDIT, amount, currencyCode,
|
||||||
|
transactionDate, user.getId(), note);
|
||||||
|
// Cashier's branch: the cash really moved here.
|
||||||
|
post(cashierOfficeId, cashGlId, deposit ? DEBIT : CREDIT, suspenseGlId, deposit ? CREDIT : DEBIT, amount, currencyCode,
|
||||||
|
transactionDate, user.getId(), note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isCashier(final Long userId) {
|
||||||
|
final Integer count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
|
||||||
|
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?", Integer.class, userId);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long liabilityTransferGlId() {
|
||||||
|
final List<Long> ids = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||||
|
FINANCIAL_ACTIVITY_LIABILITY_TRANSFER);
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
throw new IllegalStateException("A cash deposit or withdrawal for another branch's account needs the Liability Transfer "
|
||||||
|
+ "account mapped under Accounting > Financial Activity Mappings.");
|
||||||
|
}
|
||||||
|
return ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One balanced entry in one branch: the cash line and its Liability Transfer counterpart. */
|
||||||
|
private void post(final Long officeId, final Long cashGlId, final int cashType, final Long suspenseGlId, final int suspenseType,
|
||||||
|
final BigDecimal amount, final String currencyCode, final LocalDate entryDate, final long userId, final String note) {
|
||||||
|
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.INTER_BRANCH_CASH);
|
||||||
|
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
|
||||||
|
insert(officeId, cashGlId, cashType, amount, txnId, entryDate, userId, now, currencyCode, note);
|
||||||
|
insert(officeId, suspenseGlId, suspenseType, amount, txnId, entryDate, userId, now, currencyCode, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insert(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||||
|
final LocalDate entryDate, final long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||||
|
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
|
||||||
|
final java.time.OffsetDateTime utc = now.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
|
||||||
|
.withOffsetSameInstant(ZoneOffset.UTC);
|
||||||
|
this.jdbcTemplate.update(
|
||||||
|
"INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||||
|
+ " entry_date, type_enum, amount, description, created_by, last_modified_by, created_date, lastmodified_date, "
|
||||||
|
+ " is_running_balance_calculated, office_running_balance, organization_running_balance, submitted_on_date, "
|
||||||
|
+ " transaction_date, created_on_utc, last_modified_on_utc) "
|
||||||
|
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||||
|
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
|
||||||
|
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), utc, utc);
|
||||||
|
}
|
||||||
|
}
|
||||||
-67
@@ -1570,76 +1570,9 @@ public class AccountingProcessorHelper {
|
|||||||
return mapping == null ? null : mapping.getGlAccount();
|
return mapping == null ? null : mapping.getGlAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The clearing account for the savings-to-savings account transfer running on this thread, taken from a product's
|
|
||||||
* own Transfers In Suspense mapping (see {@link #useProductTransferSuspense}). Null outside such a transfer.
|
|
||||||
*/
|
|
||||||
private static final ThreadLocal<GLAccount> TRANSFER_CLEARING_ACCOUNT = new ThreadLocal<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes both legs of a savings-to-savings account transfer (savings to RD by auto debit, savings to savings, a
|
|
||||||
* deposit paying out) clear through the Transfers In Suspense account set on the product's Accounting tab, rather
|
|
||||||
* than the organisation-wide Liability Transfer financial activity.
|
|
||||||
* <p>
|
|
||||||
* The receiving product's mapping is used first, then the paying product's: a savings product screen does not
|
|
||||||
* offer Transfers In Suspense, so for savings to RD it is the RD product's mapping. Both legs use the same account,
|
|
||||||
* so it nets to nil. Where neither product has one, nothing is set and the transfer clears through Liability
|
|
||||||
* Transfer as before.
|
|
||||||
* <p>
|
|
||||||
* Must be paired with {@link #clearProductTransferSuspense()} in a finally block.
|
|
||||||
*
|
|
||||||
* @return true when a product mapping was found and is now in use
|
|
||||||
*/
|
|
||||||
public boolean useProductTransferSuspense(final Long toSavingsProductId, final Long fromSavingsProductId) {
|
|
||||||
final int transfersSuspense = CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue();
|
|
||||||
GLAccount account = optionalLinkedGLAccountForSavingsProduct(toSavingsProductId, transfersSuspense, null);
|
|
||||||
if (account == null) {
|
|
||||||
account = optionalLinkedGLAccountForSavingsProduct(fromSavingsProductId, transfersSuspense, null);
|
|
||||||
}
|
|
||||||
if (account == null) {
|
|
||||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
TRANSFER_CLEARING_ACCOUNT.set(account);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void clearProductTransferSuspense() {
|
|
||||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For undoing one leg of an account transfer: reverses through the clearing account that leg was actually posted
|
|
||||||
* to, whether a product's Transfers In Suspense or the Liability Transfer financial activity. A reversal works the
|
|
||||||
* accounts out again rather than copying the original rows, so without this a transfer posted to a product's
|
|
||||||
* suspense account would be reversed against Liability Transfer and leave both accounts open.
|
|
||||||
* <p>
|
|
||||||
* The clearing row is the credit of a withdrawal and the debit of a deposit (the largest, should a fee sit beside
|
|
||||||
* it). Nothing is set where the leg has no journal entries. Must be paired with
|
|
||||||
* {@link #clearProductTransferSuspense()} in a finally block.
|
|
||||||
*/
|
|
||||||
public void useClearingAccountPostedFor(final Long savingsTransactionId, final boolean isWithdrawal) {
|
|
||||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
|
||||||
if (savingsTransactionId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final List<Long> found = jdbcTemplate.queryForList("""
|
|
||||||
SELECT account_id FROM acc_gl_journal_entry
|
|
||||||
WHERE savings_transaction_id = ? AND type_enum = ? AND reversed = false
|
|
||||||
ORDER BY amount DESC, id
|
|
||||||
LIMIT 1
|
|
||||||
""", Long.class, savingsTransactionId, isWithdrawal ? JournalEntryType.CREDIT.getValue() : JournalEntryType.DEBIT.getValue());
|
|
||||||
if (!found.isEmpty()) {
|
|
||||||
glAccountRepository.findById(found.get(0)).ifPresent(TRANSFER_CLEARING_ACCOUNT::set);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||||
final Long paymentTypeId) {
|
final Long paymentTypeId) {
|
||||||
GLAccount glAccount;
|
GLAccount glAccount;
|
||||||
if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) {
|
|
||||||
return TRANSFER_CLEARING_ACCOUNT.get();
|
|
||||||
}
|
|
||||||
if (isOrganizationAccount(accountMappingTypeId)) {
|
if (isOrganizationAccount(accountMappingTypeId)) {
|
||||||
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
|
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
|
||||||
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
|
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
|
||||||
|
|||||||
+4
@@ -74,12 +74,16 @@ public final class LedgerTxnPrefix {
|
|||||||
public static final String CASH_MIGRATION = "CSHMIG";
|
public static final String CASH_MIGRATION = "CSHMIG";
|
||||||
/** Rounding difference on a clerk's day closure. */
|
/** Rounding difference on a clerk's day closure. */
|
||||||
public static final String CLOSURE_ROUNDING = "CLSRND";
|
public static final String CLOSURE_ROUNDING = "CLSRND";
|
||||||
|
/** Cash line of a deposit or withdrawal moved to the cashier's branch, when the account is held at another. */
|
||||||
|
public static final String INTER_BRANCH_CASH = "IBCASH";
|
||||||
|
|
||||||
// ── Year end ──────────────────────────────────────────────────────────────
|
// ── Year end ──────────────────────────────────────────────────────────────
|
||||||
/** Year-end transfer of income into the profit account. */
|
/** Year-end transfer of income into the profit account. */
|
||||||
public static final String YEAR_END_PROFIT = "YEPFT";
|
public static final String YEAR_END_PROFIT = "YEPFT";
|
||||||
/** Year-end closure of an income or expense account. */
|
/** Year-end closure of an income or expense account. */
|
||||||
public static final String YEAR_END_CLOSE = "YECLS";
|
public static final String YEAR_END_CLOSE = "YECLS";
|
||||||
|
/** Appropriation of a closed year's profit into the reserve funds. */
|
||||||
|
public static final String PROFIT_APPROPRIATION = "PRAPP";
|
||||||
|
|
||||||
// ── Savings ───────────────────────────────────────────────────────────────
|
// ── Savings ───────────────────────────────────────────────────────────────
|
||||||
/** Pooled savings interest posting for one product and date. */
|
/** Pooled savings interest posting for one product and date. */
|
||||||
|
|||||||
+3
-2
@@ -366,7 +366,7 @@ public class IncomeProfitTransferService {
|
|||||||
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveCurrency(final Long glAccountId) {
|
public String resolveCurrency(final Long glAccountId) {
|
||||||
try {
|
try {
|
||||||
final String code = jdbcTemplate.queryForObject(
|
final String code = jdbcTemplate.queryForObject(
|
||||||
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
|
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
|
||||||
@@ -377,7 +377,8 @@ public class IncomeProfitTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
/** Writes one journal entry leg. Shared with the profit appropriation posting so both year-end steps post alike. */
|
||||||
|
public void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
|
||||||
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
|
||||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
|
|||||||
+11
-13
@@ -124,8 +124,6 @@ public class SecurityConfig {
|
|||||||
private ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
|
private ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
|
||||||
@Autowired
|
@Autowired
|
||||||
private PlatformUserDetailsChecker platformUserDetailsChecker;
|
private PlatformUserDetailsChecker platformUserDetailsChecker;
|
||||||
@org.springframework.beans.factory.annotation.Value("${fineract.i4c.tenant-identifier:}")
|
|
||||||
private String i4cTenantIdentifier;
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
@@ -166,16 +164,6 @@ public class SecurityConfig {
|
|||||||
// packages here without a Fineract Bearer token; security is enforced by the
|
// packages here without a Fineract Bearer token; security is enforced by the
|
||||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
|
.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
|
// 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
|
// 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
|
// is issued by inserting directly into m_api_client in the tenant-store database
|
||||||
@@ -250,6 +238,11 @@ public class SecurityConfig {
|
|||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
|
||||||
|
// current accounts are deposit accounts: their notes use the savings-note permissions
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes/*"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/groups/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/groups/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE")
|
||||||
// notes: create
|
// notes: create
|
||||||
@@ -261,6 +254,8 @@ public class SecurityConfig {
|
|||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currentaccounts/*/notes"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/groups/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/groups/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE")
|
||||||
// notes: update
|
// notes: update
|
||||||
@@ -272,6 +267,8 @@ public class SecurityConfig {
|
|||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currentaccounts/*/notes/*"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/groups/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/groups/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE")
|
||||||
// notes: delete
|
// notes: delete
|
||||||
@@ -283,6 +280,8 @@ public class SecurityConfig {
|
|||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/currentaccounts/*/notes/*"))
|
||||||
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/groups/*/notes"))
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/groups/*/notes"))
|
||||||
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE")
|
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE")
|
||||||
// document: clients
|
// document: clients
|
||||||
@@ -562,7 +561,6 @@ public class SecurityConfig {
|
|||||||
userNotificationService, basicAuthTenantDetailsService, businessDateReadPlatformService);
|
userNotificationService, basicAuthTenantDetailsService, businessDateReadPlatformService);
|
||||||
|
|
||||||
filter.setRequestMatcher(API_MATCHER.matcher("/api/**"));
|
filter.setRequestMatcher(API_MATCHER.matcher("/api/**"));
|
||||||
filter.setI4cDefaultTenantIdentifier(i4cTenantIdentifier);
|
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ public class TenantMenuAccessApiResource {
|
|||||||
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
|
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
|
||||||
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
|
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
|
||||||
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
|
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
|
||||||
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
|
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT", "MENU_CREDIT_BUREAU");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
|
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
|
||||||
|
|||||||
+13
-43
@@ -144,22 +144,14 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
|
|
||||||
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
|
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
|
||||||
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
|
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
|
||||||
|
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt,
|
||||||
|
transactionDate, transactionAmount, paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
|
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
|
||||||
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
|
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
// As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped.
|
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||||
final SavingsAccountTransaction withdrawal;
|
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||||
final SavingsAccountTransaction deposit;
|
|
||||||
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
|
|
||||||
try {
|
|
||||||
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, transactionDate, transactionAmount,
|
|
||||||
paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
|
|
||||||
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, transactionAmount,
|
|
||||||
paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
|
||||||
} finally {
|
|
||||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
|
||||||
}
|
|
||||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||||
|
|
||||||
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
||||||
@@ -285,27 +277,14 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
if (accountTransfer.getToLoanTransaction() != null) {
|
if (accountTransfer.getToLoanTransaction() != null) {
|
||||||
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
|
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
|
||||||
}
|
}
|
||||||
// Each leg is reversed through the clearing account it was posted to (a product's Transfers In Suspense or
|
|
||||||
// Liability Transfer), so the reversal clears that same account.
|
|
||||||
if (accountTransfer.getFromTransaction() != null) {
|
if (accountTransfer.getFromTransaction() != null) {
|
||||||
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true);
|
|
||||||
try {
|
|
||||||
this.savingsAccountWritePlatformService.undoTransaction(
|
this.savingsAccountWritePlatformService.undoTransaction(
|
||||||
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(),
|
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(),
|
||||||
accountTransfer.getFromTransaction().getId(), true);
|
true);
|
||||||
} finally {
|
|
||||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (accountTransfer.getToSavingsTransaction() != null) {
|
if (accountTransfer.getToSavingsTransaction() != null) {
|
||||||
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false);
|
this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
|
||||||
try {
|
|
||||||
this.savingsAccountWritePlatformService.undoTransaction(
|
|
||||||
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
|
|
||||||
accountTransfer.getToSavingsTransaction().getId(), true);
|
accountTransfer.getToSavingsTransaction().getId(), true);
|
||||||
} finally {
|
|
||||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
accountTransfer.reverse();
|
accountTransfer.reverse();
|
||||||
this.accountTransferRepository.save(accountTransfer);
|
this.accountTransferRepository.save(accountTransfer);
|
||||||
@@ -428,22 +407,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
transactionDate = transactionDate.plusDays(1);
|
transactionDate = transactionDate.plusDays(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving
|
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount,
|
||||||
// product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping.
|
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
||||||
final SavingsAccountTransaction withdrawal;
|
accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||||
final SavingsAccountTransaction deposit;
|
|
||||||
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
|
|
||||||
try {
|
|
||||||
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, accountTransferDTO.getFmt(),
|
|
||||||
transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
|
|
||||||
transactionBooleanValues, backdatedTxnsAllowedTill);
|
|
||||||
|
|
||||||
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate,
|
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
|
||||||
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer,
|
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
||||||
isRegularTransaction, backdatedTxnsAllowedTill);
|
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||||
} finally {
|
|
||||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
|
||||||
}
|
|
||||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||||
|
|
||||||
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
||||||
|
|||||||
+20
-4
@@ -361,9 +361,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
|
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
|
||||||
// this row's cycle within the chain, so a client's account list can collapse a renewed
|
// this row's cycle within the chain, so a client's account list can collapse a renewed
|
||||||
// FD into one entry rather than listing every term as a separate account.
|
// FD into one entry rather than listing every term as a separate account.
|
||||||
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
|
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo, ");
|
||||||
|
// The branch the account is held at: its own office when opened at another branch, else the client's or group's.
|
||||||
|
accountsSummary.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName ");
|
||||||
accountsSummary.append("from m_savings_account sa ");
|
accountsSummary.append("from m_savings_account sa ");
|
||||||
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
|
||||||
|
accountsSummary.append("left join m_client acct_client on acct_client.id = sa.client_id ");
|
||||||
|
accountsSummary.append("left join m_group acct_group on acct_group.id = sa.group_id ");
|
||||||
|
accountsSummary.append(
|
||||||
|
"left join m_office acct_office on acct_office.id = coalesce(sa.office_id, acct_client.office_id, acct_group.office_id) ");
|
||||||
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
|
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
|
||||||
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
|
||||||
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
|
||||||
@@ -464,9 +470,12 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
final String fdAccountNumber = rs.getString("fdAccountNumber");
|
final String fdAccountNumber = rs.getString("fdAccountNumber");
|
||||||
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
|
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
|
||||||
|
|
||||||
|
final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId");
|
||||||
|
final String accountOfficeName = rs.getString("accountOfficeName");
|
||||||
|
|
||||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
|
||||||
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
|
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo, accountOfficeId, accountOfficeName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,9 +505,13 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
sb.append("ca.currency_code as currencyCode, ca.currency_digits as currencyDigits, ca.currency_multiplesof as inMultiplesOf, ");
|
sb.append("ca.currency_code as currencyCode, ca.currency_digits as currencyDigits, ca.currency_multiplesof as inMultiplesOf, ");
|
||||||
sb.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
sb.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
|
||||||
sb.append("curr.display_symbol as currencyDisplaySymbol, ");
|
sb.append("curr.display_symbol as currencyDisplaySymbol, ");
|
||||||
sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName ");
|
sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName, ");
|
||||||
|
// The branch the account is held at: its own office, else the client's.
|
||||||
|
sb.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName ");
|
||||||
sb.append("from m_current_account ca ");
|
sb.append("from m_current_account ca ");
|
||||||
sb.append("join m_current_product cp on cp.id = ca.product_id ");
|
sb.append("join m_current_product cp on cp.id = ca.product_id ");
|
||||||
|
sb.append("left join m_client acct_client on acct_client.id = ca.client_id ");
|
||||||
|
sb.append("left join m_office acct_office on acct_office.id = coalesce(ca.office_id, acct_client.office_id) ");
|
||||||
sb.append("join m_currency curr on curr.code = ca.currency_code ");
|
sb.append("join m_currency curr on curr.code = ca.currency_code ");
|
||||||
sb.append("left join m_appuser sbu on sbu.id = ca.submittedon_userid ");
|
sb.append("left join m_appuser sbu on sbu.id = ca.submittedon_userid ");
|
||||||
sb.append("left join m_appuser rbu on rbu.id = ca.rejectedon_userid ");
|
sb.append("left join m_appuser rbu on rbu.id = ca.rejectedon_userid ");
|
||||||
@@ -578,10 +591,13 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
|
|||||||
|
|
||||||
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
|
||||||
|
|
||||||
|
final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId");
|
||||||
|
final String accountOfficeName = rs.getString("accountOfficeName");
|
||||||
|
|
||||||
// Current accounts have no deposit term, so no FD renewal chain to report.
|
// Current accounts have no deposit term, so no FD renewal chain to report.
|
||||||
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
|
||||||
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
|
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
|
||||||
null, null);
|
null, null, accountOfficeId, accountOfficeName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+672
@@ -0,0 +1,672 @@
|
|||||||
|
/**
|
||||||
|
* 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.almreturn.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.BadRequestException;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Date;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixed-term part of the Structural Liquidity Statement: what the bank's customers' Fixed
|
||||||
|
* Deposits, Recurring Deposits and term loans add up to in each residual-maturity bucket, counted
|
||||||
|
* from an as-on date.
|
||||||
|
*
|
||||||
|
* <h3>How each figure is slotted</h3>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Fixed and Recurring Deposits</b> (outflow): the account balance, on the deposit's maturity
|
||||||
|
* date. RBI slots term deposits by residual maturity; accrued interest is a separate line of the
|
||||||
|
* statement ("Interest Payable"), so it is not added here. A deposit already at or past its
|
||||||
|
* maturity is an overdue liability and goes in the first bucket.</li>
|
||||||
|
* <li><b>Term loans</b> (inflow): each unpaid instalment's principal, on that instalment's due date -
|
||||||
|
* RBI asks for the interim cash flows, not the whole loan in one bucket. An instalment already due
|
||||||
|
* is overdue: one that fell due less than a month ago goes in the "recent" bucket and an older one
|
||||||
|
* in the "old" bucket. Both bucket positions are parameters, because they are the bank's
|
||||||
|
* Overdue Receivables rule and not something this code should decide.</li>
|
||||||
|
* <li><b>Not slotted by date</b>, reported on their own so they are not silently lost: NPA loans (RBI
|
||||||
|
* slots them by classification, not by instalment) and cash-credit / overdraft / demand loans (their
|
||||||
|
* stored schedule is a synthetic equal-instalment one, not a real repayment pattern).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>What this does not cover</h3>
|
||||||
|
*
|
||||||
|
* The bank's own placements and borrowings - FDs with other banks, investments, borrowings - have no
|
||||||
|
* register in the system, only ledger balances, so there is nothing to slot by date. The response
|
||||||
|
* says so rather than leaving them out silently.
|
||||||
|
*
|
||||||
|
* <h3>As-on date</h3>
|
||||||
|
*
|
||||||
|
* Accounts are read in their state today. The as-on date only decides where a maturity falls
|
||||||
|
* relative to it; it does not rebuild what the books looked like on an earlier day.
|
||||||
|
*
|
||||||
|
* <h3>Buckets</h3>
|
||||||
|
*
|
||||||
|
* The bucket edges arrive from the caller, since the bank edits them. {@code buckets=14d,28d,3m,6m,1y,3y,5y}
|
||||||
|
* means the first bucket ends 14 days out, the second 28 days out, the third 3 calendar months out
|
||||||
|
* and so on, with a last bucket that has no end. Months and years are calendar ones.
|
||||||
|
*/
|
||||||
|
@Path("/v1/almreturn/maturity-breakdown")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class AlmMaturityBreakdownApiResource {
|
||||||
|
|
||||||
|
/** Same audience as the other RBI returns, so no separate permission is introduced for it. */
|
||||||
|
private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN";
|
||||||
|
|
||||||
|
/** The bucket edges used when the caller names none: RBI's non-scheduled UCB structure. */
|
||||||
|
private static final String DEFAULT_BUCKETS = "14d,28d,3m,6m,1y,3y,5y";
|
||||||
|
|
||||||
|
private static final int FIXED_DEPOSIT = 200;
|
||||||
|
private static final int RECURRING_DEPOSIT = 300;
|
||||||
|
|
||||||
|
/** SavingsAccountStatusType ACTIVE and MATURED: a matured deposit is still a liability until closed or renewed. */
|
||||||
|
private static final int SAVINGS_ACTIVE = 300;
|
||||||
|
private static final int SAVINGS_MATURED = 800;
|
||||||
|
|
||||||
|
/** LoanStatus.ACTIVE. */
|
||||||
|
private static final int LOAN_ACTIVE = 300;
|
||||||
|
|
||||||
|
private static final Pattern EDGE = Pattern.compile("^(\\d{1,4})([dmy])$");
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public String retrieve(@QueryParam("asOnDate") final String asOnDateParam, @QueryParam("officeId") final Long officeId,
|
||||||
|
@QueryParam("buckets") final String bucketsParam, @QueryParam("overdueRecentBucket") final Integer overdueRecentBucket,
|
||||||
|
@QueryParam("overdueOldBucket") final Integer overdueOldBucket) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final LocalDate asOn = parseDate(asOnDateParam);
|
||||||
|
final List<LocalDate> limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam);
|
||||||
|
final int bucketCount = limits.size() + 1;
|
||||||
|
final int recent = clampBucket(overdueRecentBucket, 0, bucketCount);
|
||||||
|
final int old = clampBucket(overdueOldBucket, 2, bucketCount);
|
||||||
|
final String hierarchy = scopeHierarchy(officeId) + "%";
|
||||||
|
|
||||||
|
final List<Map<String, Object>> groups = new ArrayList<>();
|
||||||
|
groups.add(depositGroup("FD", "Fixed Deposits", FIXED_DEPOSIT, asOn, limits, hierarchy));
|
||||||
|
groups.add(depositGroup("RD", "Recurring Deposits", RECURRING_DEPOSIT, asOn, limits, hierarchy));
|
||||||
|
groups.add(termLoanGroup(asOn, limits, hierarchy, recent, old));
|
||||||
|
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("asOnDate", asOn.toString());
|
||||||
|
response.put("officeId", officeId);
|
||||||
|
response.put("bucketCount", bucketCount);
|
||||||
|
response.put("bucketEnds", limits.stream().map(LocalDate::toString).toList());
|
||||||
|
response.put("groups", groups);
|
||||||
|
response.put("notSlotted", List.of(npaLoans(hierarchy), revolvingLoans(hierarchy)));
|
||||||
|
response.put("notes", List.of(
|
||||||
|
"Deposits are slotted by their balance on the maturity date; accrued interest belongs to the Interest Payable line.",
|
||||||
|
"Term loans are slotted instalment by instalment (principal only); accrued interest belongs to the Interest Receivable line.",
|
||||||
|
"Accounts are read in their state today; the as-on date only decides where a maturity falls.",
|
||||||
|
"The bank's own FDs with other banks, investments and borrowings have no register, so they are not part of this breakdown."));
|
||||||
|
return this.gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── Deposits ───────────────────────────
|
||||||
|
|
||||||
|
private Map<String, Object> depositGroup(final String key, final String label, final int depositType, final LocalDate asOn,
|
||||||
|
final List<LocalDate> limits, final String hierarchy) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT datp.maturity_date AS "d",
|
||||||
|
sa.status_enum AS "st",
|
||||||
|
COUNT(*) AS "n",
|
||||||
|
COALESCE(SUM(sa.account_balance_derived), 0) AS "amt"
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
|
||||||
|
WHERE sa.deposit_type_enum = ?
|
||||||
|
AND sa.status_enum IN (?, ?)
|
||||||
|
AND o.hierarchy LIKE ?
|
||||||
|
GROUP BY datp.maturity_date, sa.status_enum
|
||||||
|
""", depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy);
|
||||||
|
|
||||||
|
final BigDecimal[] buckets = zeros(limits.size() + 1);
|
||||||
|
long accounts = 0;
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final long n = ((Number) row.get("n")).longValue();
|
||||||
|
final BigDecimal amount = (BigDecimal) row.get("amt");
|
||||||
|
accounts += n;
|
||||||
|
final Date maturity = (Date) row.get("d");
|
||||||
|
// A MATURED deposit is already past its date and still owed: the first bucket, whatever the date says.
|
||||||
|
final boolean matured = ((Number) row.get("st")).intValue() == SAVINGS_MATURED;
|
||||||
|
final int index = matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits);
|
||||||
|
buckets[index] = buckets[index].add(amount);
|
||||||
|
}
|
||||||
|
return group(key, label, "liability", accounts, buckets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── Loans ───────────────────────────
|
||||||
|
|
||||||
|
private Map<String, Object> termLoanGroup(final LocalDate asOn, final List<LocalDate> limits, final String hierarchy,
|
||||||
|
final int recentOverdueBucket, final int oldOverdueBucket) {
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT rs.duedate AS "d",
|
||||||
|
COALESCE(SUM(GREATEST(rs.principal_amount
|
||||||
|
- COALESCE(rs.principal_completed_derived, 0)
|
||||||
|
- COALESCE(rs.principal_writtenoff_derived, 0), 0)), 0) AS "amt"
|
||||||
|
FROM m_loan_repayment_schedule rs
|
||||||
|
JOIN m_loan l ON l.id = rs.loan_id
|
||||||
|
LEFT JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = l.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||||
|
WHERE l.loan_status_id = ?
|
||||||
|
AND COALESCE(l.is_npa, false) = false
|
||||||
|
AND COALESCE(rs.completed_derived, false) = false
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
|
||||||
|
WHERE ext.loan_product_id = l.product_id
|
||||||
|
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
|
||||||
|
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
|
||||||
|
AND o.hierarchy LIKE ?
|
||||||
|
GROUP BY rs.duedate
|
||||||
|
""", LOAN_ACTIVE, hierarchy);
|
||||||
|
|
||||||
|
final BigDecimal[] buckets = zeros(limits.size() + 1);
|
||||||
|
final LocalDate recentCutoff = asOn.minusMonths(1);
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final LocalDate due = ((Date) row.get("d")).toLocalDate();
|
||||||
|
final BigDecimal amount = (BigDecimal) row.get("amt");
|
||||||
|
final int index;
|
||||||
|
if (due.isAfter(asOn)) {
|
||||||
|
index = bucketIndex(asOn, due, limits);
|
||||||
|
} else {
|
||||||
|
// Already due and unpaid. Under a month overdue is still expected shortly; older is not.
|
||||||
|
index = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket;
|
||||||
|
}
|
||||||
|
buckets[index] = buckets[index].add(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long loans = this.jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM m_loan l
|
||||||
|
LEFT JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = l.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||||
|
WHERE l.loan_status_id = ?
|
||||||
|
AND COALESCE(l.is_npa, false) = false
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
|
||||||
|
WHERE ext.loan_product_id = l.product_id
|
||||||
|
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
|
||||||
|
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
|
||||||
|
AND o.hierarchy LIKE ?
|
||||||
|
""", Long.class, LOAN_ACTIVE, hierarchy);
|
||||||
|
return group("LOAN", "Term Loans (principal instalments)", "asset", loans == null ? 0 : loans, buckets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NPA loans: RBI slots them by classification (sub-standard / doubtful / loss), not by instalment date. */
|
||||||
|
private Map<String, Object> npaLoans(final String hierarchy) {
|
||||||
|
return outstanding("NPA", "NPA loans", """
|
||||||
|
SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt"
|
||||||
|
FROM m_loan l
|
||||||
|
LEFT JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = l.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||||
|
WHERE l.loan_status_id = ? AND COALESCE(l.is_npa, false) = true AND o.hierarchy LIKE ?
|
||||||
|
""", "Slotted by classification (sub-standard, doubtful, loss), net of provision, not by instalment date.",
|
||||||
|
hierarchy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CC / OD / demand loans: the stored schedule is synthetic, so the volatile/core split rule applies instead. */
|
||||||
|
private Map<String, Object> revolvingLoans(final String hierarchy) {
|
||||||
|
return outstanding("REVOLVING", "CC / OD / demand loans", """
|
||||||
|
SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt"
|
||||||
|
FROM m_loan l
|
||||||
|
LEFT JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = l.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||||
|
WHERE l.loan_status_id = ?
|
||||||
|
AND COALESCE(l.is_npa, false) = false
|
||||||
|
AND EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
|
||||||
|
WHERE ext.loan_product_id = l.product_id
|
||||||
|
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
|
||||||
|
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
|
||||||
|
AND o.hierarchy LIKE ?
|
||||||
|
""", "Split into a volatile and a core share by the bank's own rule; the stored instalment schedule is not a real repayment pattern.",
|
||||||
|
hierarchy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One count-and-total line for loans that are reported apart from the date buckets. */
|
||||||
|
private Map<String, Object> outstanding(final String key, final String label, final String sql, final String why,
|
||||||
|
final String hierarchy) {
|
||||||
|
final Map<String, Object> row = this.jdbcTemplate.queryForMap(sql, LOAN_ACTIVE, hierarchy);
|
||||||
|
final Map<String, Object> line = new LinkedHashMap<>();
|
||||||
|
line.put("key", key);
|
||||||
|
line.put("label", label);
|
||||||
|
line.put("side", "asset");
|
||||||
|
line.put("accounts", ((Number) row.get("n")).longValue());
|
||||||
|
line.put("outstanding", row.get("amt"));
|
||||||
|
line.put("why", why);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── Picking accounts one by one ───────────────────────────
|
||||||
|
|
||||||
|
/** How many matches one search returns. */
|
||||||
|
private static final int SEARCH_LIMIT = 25;
|
||||||
|
|
||||||
|
/** How many accounts one report may cover. */
|
||||||
|
private static final int SELECTED_LIMIT = 200;
|
||||||
|
|
||||||
|
/** A loan product whose stored schedule is not a real repayment pattern: cash credit, overdraft, demand. */
|
||||||
|
private static final String REVOLVING_PRODUCT = "EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext"
|
||||||
|
+ " WHERE ext.loan_product_id = l.product_id"
|
||||||
|
+ " AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc') OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find fixed deposits, recurring deposits and loans by account number or customer name, so a few of
|
||||||
|
* them can be picked and broken down by days.
|
||||||
|
*
|
||||||
|
* @param kind
|
||||||
|
* FD, RD or LOAN to look in one kind only; empty to look in all three
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("search")
|
||||||
|
public String search(@QueryParam("q") final String q, @QueryParam("kind") final String kind,
|
||||||
|
@QueryParam("officeId") final Long officeId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
final String text = q == null ? "" : q.trim().toLowerCase();
|
||||||
|
final List<Map<String, Object>> results = new ArrayList<>();
|
||||||
|
if (text.length() >= 2) {
|
||||||
|
final String like = "%" + text + "%";
|
||||||
|
final String hierarchy = scopeHierarchy(officeId) + "%";
|
||||||
|
final String wanted = kind == null ? "" : kind.trim().toUpperCase();
|
||||||
|
if (wanted.isEmpty() || "FD".equals(wanted)) {
|
||||||
|
results.addAll(searchDeposits(FIXED_DEPOSIT, "FD", like, hierarchy));
|
||||||
|
}
|
||||||
|
if (wanted.isEmpty() || "RD".equals(wanted)) {
|
||||||
|
results.addAll(searchDeposits(RECURRING_DEPOSIT, "RD", like, hierarchy));
|
||||||
|
}
|
||||||
|
if (wanted.isEmpty() || "LOAN".equals(wanted)) {
|
||||||
|
results.addAll(searchLoans(like, hierarchy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("results", results);
|
||||||
|
return this.gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day-wise breakdown of the accounts that were picked: each deposit as one row on its maturity
|
||||||
|
* date, each loan as one row per unpaid instalment on its due date, every row with the days to that
|
||||||
|
* date from the as-on date and the bucket it falls in.
|
||||||
|
*
|
||||||
|
* @param accountsParam
|
||||||
|
* the picked accounts as KIND:id pairs, for example FD:12,RD:5,LOAN:33
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("selected")
|
||||||
|
public String selected(@QueryParam("accounts") final String accountsParam, @QueryParam("asOnDate") final String asOnDateParam,
|
||||||
|
@QueryParam("officeId") final Long officeId, @QueryParam("buckets") final String bucketsParam,
|
||||||
|
@QueryParam("overdueRecentBucket") final Integer overdueRecentBucket,
|
||||||
|
@QueryParam("overdueOldBucket") final Integer overdueOldBucket) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
|
||||||
|
|
||||||
|
if (accountsParam == null || accountsParam.isBlank()) {
|
||||||
|
throw new BadRequestException("accounts is required, like FD:12,RD:5,LOAN:33");
|
||||||
|
}
|
||||||
|
final LocalDate asOn = parseDate(asOnDateParam);
|
||||||
|
final List<LocalDate> limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam);
|
||||||
|
final int bucketCount = limits.size() + 1;
|
||||||
|
final String hierarchy = scopeHierarchy(officeId) + "%";
|
||||||
|
|
||||||
|
final List<Long> fixed = new ArrayList<>();
|
||||||
|
final List<Long> recurring = new ArrayList<>();
|
||||||
|
final List<Long> loans = new ArrayList<>();
|
||||||
|
for (final String token : accountsParam.split(",")) {
|
||||||
|
final String[] parts = token.trim().split(":");
|
||||||
|
if (parts.length != 2) {
|
||||||
|
throw new BadRequestException("accounts must look like FD:12,RD:5,LOAN:33");
|
||||||
|
}
|
||||||
|
final long id;
|
||||||
|
try {
|
||||||
|
id = Long.parseLong(parts[1].trim());
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
throw new BadRequestException("Account id '" + parts[1] + "' is not a number");
|
||||||
|
}
|
||||||
|
switch (parts[0].trim().toUpperCase()) {
|
||||||
|
case "FD" -> fixed.add(id);
|
||||||
|
case "RD" -> recurring.add(id);
|
||||||
|
case "LOAN" -> loans.add(id);
|
||||||
|
default -> throw new BadRequestException("Account kind '" + parts[0] + "' must be FD, RD or LOAN");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fixed.size() + recurring.size() + loans.size() > SELECTED_LIMIT) {
|
||||||
|
throw new BadRequestException("Pick at most " + SELECTED_LIMIT + " accounts at a time");
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
if (!fixed.isEmpty()) {
|
||||||
|
rows.addAll(depositRows(FIXED_DEPOSIT, "FD", fixed, asOn, limits, hierarchy));
|
||||||
|
}
|
||||||
|
if (!recurring.isEmpty()) {
|
||||||
|
rows.addAll(depositRows(RECURRING_DEPOSIT, "RD", recurring, asOn, limits, hierarchy));
|
||||||
|
}
|
||||||
|
if (!loans.isEmpty()) {
|
||||||
|
rows.addAll(instalmentRows(loans, asOn, limits, hierarchy, clampBucket(overdueRecentBucket, 0, bucketCount),
|
||||||
|
clampBucket(overdueOldBucket, 2, bucketCount)));
|
||||||
|
}
|
||||||
|
// By date, undated rows last, then by account.
|
||||||
|
rows.sort(Comparator.comparing((Map<String, Object> r) -> String.valueOf(r.get("date")))
|
||||||
|
.thenComparing(r -> String.valueOf(r.get("accountNo"))));
|
||||||
|
|
||||||
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
total = total.add((BigDecimal) row.get("amount"));
|
||||||
|
}
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("asOnDate", asOn.toString());
|
||||||
|
response.put("bucketCount", bucketCount);
|
||||||
|
response.put("count", rows.size());
|
||||||
|
response.put("total", total);
|
||||||
|
response.put("rows", rows);
|
||||||
|
return this.gson.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> searchDeposits(final int depositType, final String kind, final String like,
|
||||||
|
final String hierarchy) {
|
||||||
|
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT sa.id AS "id",
|
||||||
|
sa.account_no AS "accountNo",
|
||||||
|
COALESCE(c.display_name, g.display_name) AS "customer",
|
||||||
|
sp.name AS "product",
|
||||||
|
datp.maturity_date AS "d",
|
||||||
|
COALESCE(sa.account_balance_derived, 0) AS "amt"
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
|
||||||
|
WHERE sa.deposit_type_enum = ?
|
||||||
|
AND sa.status_enum IN (?, ?)
|
||||||
|
AND o.hierarchy LIKE ?
|
||||||
|
AND (LOWER(sa.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?)
|
||||||
|
ORDER BY sa.account_no
|
||||||
|
LIMIT ?
|
||||||
|
""", depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy, like, like, SEARCH_LIMIT);
|
||||||
|
return hits(found, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> searchLoans(final String like, final String hierarchy) {
|
||||||
|
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT l.id AS "id",
|
||||||
|
l.account_no AS "accountNo",
|
||||||
|
COALESCE(c.display_name, g.display_name) AS "customer",
|
||||||
|
lp.name AS "product",
|
||||||
|
COALESCE(l.principal_outstanding_derived, 0) AS "amt"
|
||||||
|
FROM m_loan l
|
||||||
|
JOIN m_product_loan lp ON lp.id = l.product_id
|
||||||
|
LEFT JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = l.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||||
|
WHERE l.loan_status_id = ?
|
||||||
|
AND o.hierarchy LIKE ?
|
||||||
|
AND (LOWER(l.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?)
|
||||||
|
ORDER BY l.account_no
|
||||||
|
LIMIT ?
|
||||||
|
""", LOAN_ACTIVE, hierarchy, like, like, SEARCH_LIMIT);
|
||||||
|
return hits(found, "LOAN");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Map<String, Object>> hits(final List<Map<String, Object>> found, final String kind) {
|
||||||
|
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : found) {
|
||||||
|
final Date date = (Date) r.get("d");
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("kind", kind);
|
||||||
|
row.put("id", r.get("id"));
|
||||||
|
row.put("accountNo", r.get("accountNo"));
|
||||||
|
row.put("customer", r.get("customer"));
|
||||||
|
row.put("product", r.get("product"));
|
||||||
|
row.put("date", date == null ? null : date.toLocalDate().toString());
|
||||||
|
row.put("amount", r.get("amt"));
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String placeholders(final int count) {
|
||||||
|
return String.join(",", Collections.nCopies(count, "?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> depositRows(final int depositType, final String label, final List<Long> ids,
|
||||||
|
final LocalDate asOn, final List<LocalDate> limits, final String hierarchy) {
|
||||||
|
final String sql = """
|
||||||
|
SELECT sa.id AS "id",
|
||||||
|
sa.account_no AS "accountNo",
|
||||||
|
COALESCE(c.display_name, g.display_name) AS "customer",
|
||||||
|
sp.name AS "product",
|
||||||
|
datp.maturity_date AS "d",
|
||||||
|
sa.status_enum AS "st",
|
||||||
|
COALESCE(sa.account_balance_derived, 0) AS "amt"
|
||||||
|
FROM m_savings_account sa
|
||||||
|
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
|
||||||
|
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
|
||||||
|
WHERE sa.deposit_type_enum = ?
|
||||||
|
AND sa.status_enum IN (?, ?)
|
||||||
|
AND o.hierarchy LIKE ?
|
||||||
|
AND sa.id IN (%s)
|
||||||
|
ORDER BY datp.maturity_date, sa.account_no
|
||||||
|
""".formatted(placeholders(ids.size()));
|
||||||
|
final List<Object> args = new ArrayList<>(List.of(depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy));
|
||||||
|
args.addAll(ids);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||||
|
final Date maturity = (Date) r.get("d");
|
||||||
|
final boolean matured = ((Number) r.get("st")).intValue() == SAVINGS_MATURED;
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("id", r.get("id"));
|
||||||
|
row.put("accountNo", r.get("accountNo"));
|
||||||
|
row.put("customer", r.get("customer"));
|
||||||
|
row.put("product", r.get("product"));
|
||||||
|
row.put("kind", label);
|
||||||
|
row.put("date", maturity == null ? null : maturity.toLocalDate().toString());
|
||||||
|
row.put("days", maturity == null ? null : ChronoUnit.DAYS.between(asOn, maturity.toLocalDate()));
|
||||||
|
row.put("status", matured ? "MATURED" : "ACTIVE");
|
||||||
|
// Same rule as the totals: a matured deposit is overdue and sits in the first bucket.
|
||||||
|
row.put("bucket", matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits));
|
||||||
|
row.put("amount", r.get("amt"));
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unpaid instalments of the picked loans. A picked loan is shown by its schedule whatever it is,
|
||||||
|
* with a note where the schedule is not the whole story (an NPA, or a cash credit / overdraft).
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> instalmentRows(final List<Long> ids, final LocalDate asOn, final List<LocalDate> limits,
|
||||||
|
final String hierarchy, final int recentOverdueBucket, final int oldOverdueBucket) {
|
||||||
|
final String sql = """
|
||||||
|
SELECT l.id AS "id",
|
||||||
|
l.account_no AS "accountNo",
|
||||||
|
COALESCE(c.display_name, g.display_name) AS "customer",
|
||||||
|
lp.name AS "product",
|
||||||
|
rs.installment AS "installment",
|
||||||
|
rs.duedate AS "d",
|
||||||
|
GREATEST(rs.principal_amount
|
||||||
|
- COALESCE(rs.principal_completed_derived, 0)
|
||||||
|
- COALESCE(rs.principal_writtenoff_derived, 0), 0) AS "amt",
|
||||||
|
COALESCE(l.is_npa, false) AS "npa",
|
||||||
|
(%s) AS "revolving"
|
||||||
|
FROM m_loan_repayment_schedule rs
|
||||||
|
JOIN m_loan l ON l.id = rs.loan_id
|
||||||
|
JOIN m_product_loan lp ON lp.id = l.product_id
|
||||||
|
LEFT JOIN m_client c ON c.id = l.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = l.group_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
|
||||||
|
WHERE l.loan_status_id = ?
|
||||||
|
AND COALESCE(rs.completed_derived, false) = false
|
||||||
|
AND GREATEST(rs.principal_amount
|
||||||
|
- COALESCE(rs.principal_completed_derived, 0)
|
||||||
|
- COALESCE(rs.principal_writtenoff_derived, 0), 0) > 0
|
||||||
|
AND o.hierarchy LIKE ?
|
||||||
|
AND l.id IN (%s)
|
||||||
|
ORDER BY rs.duedate, l.account_no
|
||||||
|
""".formatted(REVOLVING_PRODUCT, placeholders(ids.size()));
|
||||||
|
final List<Object> args = new ArrayList<>(List.of(LOAN_ACTIVE, hierarchy));
|
||||||
|
args.addAll(ids);
|
||||||
|
|
||||||
|
final LocalDate recentCutoff = asOn.minusMonths(1);
|
||||||
|
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||||
|
final LocalDate due = ((Date) r.get("d")).toLocalDate();
|
||||||
|
final int bucket;
|
||||||
|
if (due.isAfter(asOn)) {
|
||||||
|
bucket = bucketIndex(asOn, due, limits);
|
||||||
|
} else {
|
||||||
|
bucket = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket;
|
||||||
|
}
|
||||||
|
final boolean npa = Boolean.TRUE.equals(r.get("npa"));
|
||||||
|
final boolean revolving = Boolean.TRUE.equals(r.get("revolving"));
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("id", r.get("id"));
|
||||||
|
row.put("accountNo", r.get("accountNo"));
|
||||||
|
row.put("customer", r.get("customer"));
|
||||||
|
row.put("product", r.get("product"));
|
||||||
|
row.put("kind", "LOAN");
|
||||||
|
row.put("installment", r.get("installment"));
|
||||||
|
row.put("date", due.toString());
|
||||||
|
row.put("days", ChronoUnit.DAYS.between(asOn, due));
|
||||||
|
row.put("status", due.isAfter(asOn) ? "DUE LATER" : "OVERDUE");
|
||||||
|
row.put("bucket", bucket);
|
||||||
|
row.put("amount", r.get("amt"));
|
||||||
|
row.put("note", npa ? "NPA loan: RBI slots it by classification, not by instalment date"
|
||||||
|
: revolving ? "CC / OD loan: the stored schedule is not a real repayment pattern" : null);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── Helpers ───────────────────────────
|
||||||
|
|
||||||
|
private static Map<String, Object> group(final String key, final String label, final String side, final long accounts,
|
||||||
|
final BigDecimal[] buckets) {
|
||||||
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
for (final BigDecimal b : buckets) {
|
||||||
|
total = total.add(b);
|
||||||
|
}
|
||||||
|
final Map<String, Object> group = new LinkedHashMap<>();
|
||||||
|
group.put("key", key);
|
||||||
|
group.put("label", label);
|
||||||
|
group.put("side", side);
|
||||||
|
group.put("accounts", accounts);
|
||||||
|
group.put("total", total);
|
||||||
|
group.put("buckets", List.of(buckets));
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal[] zeros(final int size) {
|
||||||
|
final BigDecimal[] values = new BigDecimal[size];
|
||||||
|
java.util.Arrays.fill(values, BigDecimal.ZERO);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket of a maturity: due or overdue goes in the first; otherwise the first bucket whose end it does not pass. */
|
||||||
|
static int bucketIndex(final LocalDate asOn, final LocalDate maturity, final List<LocalDate> limits) {
|
||||||
|
if (!maturity.isAfter(asOn)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < limits.size(); i++) {
|
||||||
|
if (!maturity.isAfter(limits.get(i))) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limits.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "14d,28d,3m" to the dates those edges reach from the as-on date. Calendar months and years; edges must climb. */
|
||||||
|
private static List<LocalDate> parseLimits(final LocalDate asOn, final String spec) {
|
||||||
|
final List<LocalDate> limits = new ArrayList<>();
|
||||||
|
for (final String token : spec.split(",")) {
|
||||||
|
final Matcher m = EDGE.matcher(token.trim().toLowerCase());
|
||||||
|
if (!m.matches() || Integer.parseInt(m.group(1)) < 1) {
|
||||||
|
throw new BadRequestException("Bucket edge '" + token + "' is not like 14d, 3m or 1y");
|
||||||
|
}
|
||||||
|
final int n = Integer.parseInt(m.group(1));
|
||||||
|
final LocalDate limit = switch (m.group(2)) {
|
||||||
|
case "d" -> asOn.plusDays(n);
|
||||||
|
case "m" -> asOn.plusMonths(n);
|
||||||
|
default -> asOn.plusYears(n);
|
||||||
|
};
|
||||||
|
if (!limits.isEmpty() && !limit.isAfter(limits.get(limits.size() - 1))) {
|
||||||
|
throw new BadRequestException("Bucket edges must run in increasing order: " + spec);
|
||||||
|
}
|
||||||
|
limits.add(limit);
|
||||||
|
}
|
||||||
|
return limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clampBucket(final Integer requested, final int fallback, final int bucketCount) {
|
||||||
|
final int value = requested == null ? fallback : requested;
|
||||||
|
return Math.max(0, Math.min(value, bucketCount - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDate(final String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return DateUtils.getBusinessLocalDate();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(raw);
|
||||||
|
} catch (final java.time.format.DateTimeParseException e) {
|
||||||
|
throw new BadRequestException("asOnDate must be yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The office tree a caller may read: their own, or a branch under it if they asked for one. */
|
||||||
|
private String scopeHierarchy(final Long officeId) {
|
||||||
|
final String own = this.context.authenticatedUser().getOffice().getHierarchy();
|
||||||
|
if (officeId == null) {
|
||||||
|
return own;
|
||||||
|
}
|
||||||
|
final List<String> asked = this.jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class, officeId);
|
||||||
|
return !asked.isEmpty() && asked.get(0).startsWith(own) ? asked.get(0) : own;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1022
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.aml.job;
|
|||||||
import java.time.YearMonth;
|
import java.time.YearMonth;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
|
||||||
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
|
||||||
import org.springframework.batch.core.StepContribution;
|
import org.springframework.batch.core.StepContribution;
|
||||||
@@ -30,9 +31,8 @@ import org.springframework.batch.repeat.RepeatStatus;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical
|
* Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical
|
||||||
* threshold aggregation only. Admin-triggered today (see {@code AmlCtrReportApiResource}); wiring
|
* threshold aggregation only. Scheduled for 02:00 on the 1st (changelog 0606), and still available from the
|
||||||
* to a real monthly cron is a follow-up, matching the same "driven by an admin endpoint today"
|
* admin endpoint (see {@code AmlCtrReportApiResource}) for a month run by hand.
|
||||||
* stance already used for {@code NeftBatchProcessorService}.
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -42,7 +42,9 @@ public class AmlCtrReportBatchTasklet implements Tasklet {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||||
final YearMonth previousMonth = YearMonth.now().minusMonths(1);
|
// The business date, not the wall clock: the month that has just ended is the bank's month, and the two dates
|
||||||
|
// are not always the same day.
|
||||||
|
final YearMonth previousMonth = YearMonth.from(DateUtils.getBusinessLocalDate()).minusMonths(1);
|
||||||
try {
|
try {
|
||||||
final AmlCtrReport report = generationService.generateForMonth(previousMonth);
|
final AmlCtrReport report = generationService.generateForMonth(previousMonth);
|
||||||
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
|
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
|
||||||
|
|||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.api;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import jakarta.ws.rs.DefaultValue;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.CacheControl;
|
|
||||||
import jakarta.ws.rs.core.Context;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import jakarta.ws.rs.sse.Sse;
|
|
||||||
import jakarta.ws.rs.sse.SseEventSink;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService;
|
|
||||||
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu.
|
|
||||||
* Numbers only: opening a tab still loads that tab's own list. The counting is in
|
|
||||||
* {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}.
|
|
||||||
*/
|
|
||||||
@Path("/v1/approvals/counts")
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public class ApprovalCountsApiResource {
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final ApprovalCountsReadPlatformService readService;
|
|
||||||
private final ApprovalCountsStreamService streamService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held
|
|
||||||
* for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) {
|
|
||||||
final AppUser user = this.context.authenticatedUser();
|
|
||||||
final CacheControl noStore = new CacheControl();
|
|
||||||
noStore.setNoStore(true);
|
|
||||||
return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change,
|
|
||||||
* so a screen neither polls nor reloads. The first event carries the numbers as they stand.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("stream")
|
|
||||||
@Produces(MediaType.SERVER_SENT_EVENTS)
|
|
||||||
public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) {
|
|
||||||
// Ask reverse proxies not to hold the events back.
|
|
||||||
response.setHeader("X-Accel-Buffering", "no");
|
|
||||||
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
||||||
try {
|
|
||||||
this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse);
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
sink.close();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Approval Center tab and the question that counts what waits on it.
|
|
||||||
*
|
|
||||||
* @param key
|
|
||||||
* the tab's route segment, which is also the key its badge reads on the screen
|
|
||||||
* @param permission
|
|
||||||
* the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees
|
|
||||||
* the tab
|
|
||||||
* @param sql
|
|
||||||
* a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan}
|
|
||||||
*/
|
|
||||||
public record ApprovalCounter(String key, String permission, String sql) {}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.data;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal
|
|
||||||
* exactly when the numbers are, which is how a change is told from a repeat.
|
|
||||||
*/
|
|
||||||
public record ApprovalCountsData(long total, Map<String, Long> counts, String version) {
|
|
||||||
|
|
||||||
private static final Gson GSON = new Gson();
|
|
||||||
|
|
||||||
public static ApprovalCountsData of(final Map<String, Long> counts) {
|
|
||||||
final long total = counts.values().stream().mapToLong(Long::longValue).sum();
|
|
||||||
return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public String toJson() {
|
|
||||||
final Map<String, Object> json = new LinkedHashMap<>();
|
|
||||||
json.put("total", this.total);
|
|
||||||
json.put("counts", this.counts);
|
|
||||||
json.put("version", this.version);
|
|
||||||
return GSON.toJson(json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.data;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those
|
|
||||||
* tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then
|
|
||||||
* be used from any thread.
|
|
||||||
*
|
|
||||||
* @param tenant
|
|
||||||
* the tenant the counts are read from
|
|
||||||
* @param visible
|
|
||||||
* the tabs this caller may see, in the order the Approval Center shows them
|
|
||||||
* @param params
|
|
||||||
* the named parameters the tabs' queries use
|
|
||||||
* @param group
|
|
||||||
* equal for callers who are counted identically, so they can share one query
|
|
||||||
*/
|
|
||||||
public record ApprovalCountsPlan(FineractPlatformTenant tenant, List<ApprovalCounter> visible, MapSqlParameterSource params,
|
|
||||||
String group) {}
|
|
||||||
-122
@@ -1,122 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.service;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the
|
|
||||||
* one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new
|
|
||||||
* tab, add one line here and one badge on the screen.
|
|
||||||
*
|
|
||||||
* The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head
|
|
||||||
* office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command).
|
|
||||||
*/
|
|
||||||
public final class ApprovalCounterRegistry {
|
|
||||||
|
|
||||||
private ApprovalCounterRegistry() {}
|
|
||||||
|
|
||||||
/** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */
|
|
||||||
private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
|
|
||||||
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'";
|
|
||||||
|
|
||||||
/** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */
|
|
||||||
private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
|
|
||||||
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'";
|
|
||||||
|
|
||||||
/** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */
|
|
||||||
private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'";
|
|
||||||
|
|
||||||
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
|
|
||||||
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
|
|
||||||
|
|
||||||
/** 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)) "
|
|
||||||
+ "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id "
|
|
||||||
+ "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid "
|
|
||||||
+ "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name "
|
|
||||||
+ "AND p.entity_name = aud.entity_name)) ";
|
|
||||||
|
|
||||||
/** Where the person who raised a loan or account sits, for the office scope the tab applies. */
|
|
||||||
private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) "
|
|
||||||
+ "AND o.hierarchy LIKE :h)";
|
|
||||||
|
|
||||||
/** The one tab whose list is open to managers and head cashiers only. */
|
|
||||||
public static final String DISBURSEMENT_REQUESTS = "disbursement-requests";
|
|
||||||
|
|
||||||
/** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */
|
|
||||||
public static final List<ApprovalCounter> ALL = List.of(
|
|
||||||
// ── 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')"),
|
|
||||||
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)"),
|
|
||||||
new ApprovalCounter("loan-approval", "READ_LOAN",
|
|
||||||
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
|
|
||||||
+ "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE),
|
|
||||||
new ApprovalCounter("loan-disbursal", "READ_LOAN",
|
|
||||||
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
|
|
||||||
+ "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE),
|
|
||||||
new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN",
|
|
||||||
"SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"),
|
|
||||||
new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id "
|
|
||||||
+ "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 "
|
|
||||||
+ "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))"),
|
|
||||||
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'))"),
|
|
||||||
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') "
|
|
||||||
+ "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"),
|
|
||||||
new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"),
|
|
||||||
// ── Requests raised by clerks, cashiers and managers ──────────────────────────
|
|
||||||
new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"),
|
|
||||||
new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id "
|
|
||||||
+ "WHERE p.status = 100"),
|
|
||||||
new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id "
|
|
||||||
+ "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id "
|
|
||||||
+ "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"),
|
|
||||||
new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dds-approvals", null,
|
|
||||||
"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",
|
|
||||||
"SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dd-issue-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dd-reprint-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dd-revoke-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("risk-change-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'"));
|
|
||||||
}
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.service;
|
|
||||||
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
|
|
||||||
/** Reads how many requests wait on each Approval Center tab, in one statement. */
|
|
||||||
public interface ApprovalCountsReadPlatformService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the
|
|
||||||
* tenant of the current request.
|
|
||||||
*/
|
|
||||||
ApprovalCountsPlan planFor(AppUser user);
|
|
||||||
|
|
||||||
/** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */
|
|
||||||
ApprovalCountsData retrieve(ApprovalCountsPlan plan);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together
|
|
||||||
* cost the database one query. {@code fresh} skips the held answer.
|
|
||||||
*/
|
|
||||||
ApprovalCountsData retrieveHeld(AppUser user, boolean fresh);
|
|
||||||
}
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.service;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is
|
|
||||||
* one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService {
|
|
||||||
|
|
||||||
/** Screens asking together read one answer instead of each running the statement. */
|
|
||||||
private static final long HOLD_MILLIS = 3_000L;
|
|
||||||
|
|
||||||
private record Held(long at, ApprovalCountsData data) {}
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final Map<String, Held> held = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ApprovalCountsPlan planFor(final AppUser user) {
|
|
||||||
final List<ApprovalCounter> visible = new ArrayList<>();
|
|
||||||
for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) {
|
|
||||||
if (isVisible(user, counter)) {
|
|
||||||
visible.add(counter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final String hierarchy = user.getOffice().getHierarchy();
|
|
||||||
final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER");
|
|
||||||
final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%")
|
|
||||||
.addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker);
|
|
||||||
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
|
|
||||||
// Callers counted the same way share one query: only a limited checker's numbers depend on who they are.
|
|
||||||
final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|'
|
|
||||||
+ (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(","));
|
|
||||||
return new ApprovalCountsPlan(tenant, visible, params, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) {
|
|
||||||
final Map<String, Long> counts = new LinkedHashMap<>();
|
|
||||||
final List<ApprovalCounter> visible = plan.visible();
|
|
||||||
if (!visible.isEmpty()) {
|
|
||||||
final StringBuilder sql = new StringBuilder("SELECT ");
|
|
||||||
for (int i = 0; i < visible.size(); i++) {
|
|
||||||
sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i);
|
|
||||||
}
|
|
||||||
final Map<String, Object> row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(),
|
|
||||||
plan.params());
|
|
||||||
for (int i = 0; i < visible.size(); i++) {
|
|
||||||
final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L;
|
|
||||||
if (n > 0) {
|
|
||||||
counts.put(visible.get(i).key(), n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ApprovalCountsData.of(counts);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) {
|
|
||||||
final ApprovalCountsPlan plan = planFor(user);
|
|
||||||
final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId();
|
|
||||||
final long now = System.currentTimeMillis();
|
|
||||||
final Held recent = this.held.get(holdKey);
|
|
||||||
if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) {
|
|
||||||
return recent.data();
|
|
||||||
}
|
|
||||||
final ApprovalCountsData data = retrieve(plan);
|
|
||||||
this.held.put(holdKey, new Held(now, data));
|
|
||||||
if (this.held.size() > 500) {
|
|
||||||
this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */
|
|
||||||
private boolean isVisible(final AppUser user, final ApprovalCounter counter) {
|
|
||||||
if (counter.permission() != null) {
|
|
||||||
final boolean allowed = counter.permission().startsWith("READ_")
|
|
||||||
? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ")
|
|
||||||
: user.hasAnyPermission(counter.permission());
|
|
||||||
if (!allowed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isChecker(final AppUser user) {
|
|
||||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
|
||||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-145
@@ -1,145 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.service;
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import jakarta.annotation.PreDestroy;
|
|
||||||
import jakarta.ws.rs.sse.OutboundSseEvent;
|
|
||||||
import jakarta.ws.rs.sse.Sse;
|
|
||||||
import jakarta.ws.rs.sse.SseEventSink;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its
|
|
||||||
* numbers change, so it neither polls nor reloads.
|
|
||||||
*
|
|
||||||
* One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share
|
|
||||||
* its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the
|
|
||||||
* server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean
|
|
||||||
* connection, which bounds what a browser accumulates on one.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ApprovalCountsStreamService {
|
|
||||||
|
|
||||||
/** How often the open screens are looked at: the longest a change waits before it is pushed. */
|
|
||||||
private static final long CHECK_MILLIS = 1_000L;
|
|
||||||
|
|
||||||
private static final long PING_MILLIS = 15_000L;
|
|
||||||
|
|
||||||
private static final long LIFETIME_MILLIS = 300_000L;
|
|
||||||
|
|
||||||
/** A screen listening, and the version of the numbers it was last sent. */
|
|
||||||
private static final class Listener {
|
|
||||||
|
|
||||||
private final ApprovalCountsPlan plan;
|
|
||||||
private final SseEventSink sink;
|
|
||||||
private final Sse sse;
|
|
||||||
private final long expiresAt;
|
|
||||||
private volatile String version;
|
|
||||||
private volatile long lastSent;
|
|
||||||
|
|
||||||
private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) {
|
|
||||||
this.plan = plan;
|
|
||||||
this.sink = sink;
|
|
||||||
this.sse = sse;
|
|
||||||
this.expiresAt = expiresAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final ApprovalCountsReadPlatformService readService;
|
|
||||||
private final Set<Listener> listeners = ConcurrentHashMap.newKeySet();
|
|
||||||
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
|
||||||
final Thread thread = new Thread(runnable, "approval-counts-stream");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
void startTicker() {
|
|
||||||
this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
void stopTicker() {
|
|
||||||
this.ticker.shutdownNow();
|
|
||||||
this.listeners.forEach(this::close);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */
|
|
||||||
public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) {
|
|
||||||
final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS);
|
|
||||||
try {
|
|
||||||
publish(listener, this.readService.retrieve(plan));
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
sink.close();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
this.listeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */
|
|
||||||
private void tick() {
|
|
||||||
if (this.listeners.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final long now = System.currentTimeMillis();
|
|
||||||
final Map<String, ApprovalCountsData> computed = new HashMap<>();
|
|
||||||
for (final Listener listener : this.listeners) {
|
|
||||||
try {
|
|
||||||
if (listener.sink.isClosed() || now >= listener.expiresAt) {
|
|
||||||
close(listener);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ApprovalCountsData data = computed.get(listener.plan.group());
|
|
||||||
if (data == null) {
|
|
||||||
ThreadLocalContextUtil.setTenant(listener.plan.tenant());
|
|
||||||
data = this.readService.retrieve(listener.plan);
|
|
||||||
computed.put(listener.plan.group(), data);
|
|
||||||
}
|
|
||||||
if (!data.version().equals(listener.version)) {
|
|
||||||
publish(listener, data);
|
|
||||||
} else if (now - listener.lastSent >= PING_MILLIS) {
|
|
||||||
listener.lastSent = now;
|
|
||||||
send(listener, listener.sse.newEventBuilder().comment("ping").build());
|
|
||||||
}
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
close(listener);
|
|
||||||
} finally {
|
|
||||||
ThreadLocalContextUtil.clearTenant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void publish(final Listener listener, final ApprovalCountsData data) {
|
|
||||||
listener.version = data.version();
|
|
||||||
listener.lastSent = System.currentTimeMillis();
|
|
||||||
send(listener, listener.sse.newEvent("counts", data.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void send(final Listener listener, final OutboundSseEvent event) {
|
|
||||||
listener.sink.send(event).whenComplete((sent, failure) -> {
|
|
||||||
if (failure != null) {
|
|
||||||
close(listener);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void close(final Listener listener) {
|
|
||||||
this.listeners.remove(listener);
|
|
||||||
try {
|
|
||||||
listener.sink.close();
|
|
||||||
} catch (final RuntimeException ignored) {
|
|
||||||
// The screen is already gone.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+88
-4
@@ -100,6 +100,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
private final VoucherService voucherService;
|
private final VoucherService voucherService;
|
||||||
private final FixedDepositFundingValidator fixedDepositFundingValidator;
|
private final FixedDepositFundingValidator fixedDepositFundingValidator;
|
||||||
|
private final org.apache.fineract.accounting.interbranch.service.InterBranchCashService interBranchCashService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
|
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
|
||||||
@@ -216,8 +217,9 @@ public class CashierDepositRequestApiResource {
|
|||||||
@QueryParam("fromDate") final String fromDate,
|
@QueryParam("fromDate") final String fromDate,
|
||||||
@QueryParam("toDate") final String toDate) {
|
@QueryParam("toDate") final String toDate) {
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
// A branch reviews only the requests its own counters raised (and those of branches below it).
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + REQUEST_BRANCH_IN_HIERARCHY);
|
||||||
|
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
baseParams.add(Date.valueOf(fromDate));
|
baseParams.add(Date.valueOf(fromDate));
|
||||||
@@ -246,9 +248,13 @@ public class CashierDepositRequestApiResource {
|
|||||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||||
pageParams.toArray());
|
pageParams.toArray());
|
||||||
|
|
||||||
|
final List<Map<String, Object>> pageItems = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
pageItems.add(withBranches(new java.util.LinkedHashMap<>(row)));
|
||||||
|
}
|
||||||
final JsonObject result = new JsonObject();
|
final JsonObject result = new JsonObject();
|
||||||
result.addProperty("totalFilteredRecords", total);
|
result.addProperty("totalFilteredRecords", total);
|
||||||
result.add("pageItems", gson.toJsonTree(rows));
|
result.add("pageItems", gson.toJsonTree(pageItems));
|
||||||
return gson.toJson(result);
|
return gson.toJson(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +319,82 @@ public class CashierDepositRequestApiResource {
|
|||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String getById(@PathParam("id") final Long id) {
|
public String getById(@PathParam("id") final Long id) {
|
||||||
return gson.toJson(loadRecord(id));
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record);
|
||||||
|
final Map<String, Object> detail = new java.util.LinkedHashMap<>();
|
||||||
|
record.forEach((key, value) -> detail.put(String.valueOf(key), value));
|
||||||
|
return gson.toJson(withBranches(detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the two branches a deposit request involves: where the cash was taken (the cashier's counter) and where the
|
||||||
|
* account is held. When they differ the request is an inter-branch deposit, which the reviewer must be able to see.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> withBranches(final Map<String, Object> row) {
|
||||||
|
final Object requestId = row.get("id");
|
||||||
|
final List<Map<String, Object>> counter = jdbcTemplate.queryForList(
|
||||||
|
"SELECT o.id, o.name FROM m_cashier_deposit_request r LEFT JOIN m_appuser au ON au.id = r.cashier_id "
|
||||||
|
+ "JOIN m_office o ON o.id = COALESCE(r.counter_office_id, au.office_id) WHERE r.id = ?", requestId);
|
||||||
|
final List<Map<String, Object>> account = jdbcTemplate.queryForList("""
|
||||||
|
SELECT o.id, o.name FROM m_cashier_deposit_request r
|
||||||
|
LEFT JOIN m_savings_account sa ON sa.id = r.savings_account_id AND sa.account_no = r.account_no
|
||||||
|
LEFT JOIN m_client sc ON sc.id = sa.client_id
|
||||||
|
LEFT JOIN m_current_account ca ON ca.id = r.savings_account_id AND ca.account_no = r.account_no
|
||||||
|
LEFT JOIN m_client cc ON cc.id = ca.client_id
|
||||||
|
JOIN m_office o ON o.id = COALESCE(CASE WHEN r.account_type = 'GL' THEN r.office_id END,
|
||||||
|
sa.office_id, sc.office_id, ca.office_id, cc.office_id)
|
||||||
|
WHERE r.id = ?
|
||||||
|
""", requestId);
|
||||||
|
final Long counterId = counter.isEmpty() ? null : ((Number) counter.get(0).get("id")).longValue();
|
||||||
|
final Long accountId = account.isEmpty() ? null : ((Number) account.get(0).get("id")).longValue();
|
||||||
|
row.put("counter_office_name", counter.isEmpty() ? null : counter.get(0).get("name"));
|
||||||
|
row.put("account_office_name", account.isEmpty() ? null : account.get(0).get("name"));
|
||||||
|
row.put("counter_branch_id", counterId);
|
||||||
|
row.put("account_branch_id", accountId);
|
||||||
|
row.put("inter_branch", counterId != null && accountId != null && !counterId.equals(accountId));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cash deposit posts in the account's branch, but the cash is in the drawer of the cashier who took it. When those
|
||||||
|
* are different branches, moves the cash line of the posting to the counter's branch through Liability Transfer.
|
||||||
|
* Only a cash payment has a cash line, and only a savings-table deposit (savings, FD, RD) is covered here.
|
||||||
|
*/
|
||||||
|
private void moveCashToCounterBranch(final Map<?, ?> record, final String txnId, final BigDecimal amount, final String txnDateStr) {
|
||||||
|
final Object paymentType = record.get("payment_type_name");
|
||||||
|
final Long savingsTxnId = parseTxnIdToLong(txnId);
|
||||||
|
if (savingsTxnId == null || paymentType == null || !"cash".equalsIgnoreCase(paymentType.toString().trim())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Map<String, Object> seed = new java.util.LinkedHashMap<>();
|
||||||
|
seed.put("id", record.get("id"));
|
||||||
|
final Map<String, Object> branches = withBranches(seed);
|
||||||
|
final Long counterId = (Long) branches.get("counter_branch_id");
|
||||||
|
final Long accountId = (Long) branches.get("account_branch_id");
|
||||||
|
if (counterId == null || accountId == null || counterId.equals(accountId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final java.time.LocalDate date = txnDateStr != null && !txnDateStr.isBlank() ? java.time.LocalDate.parse(txnDateStr)
|
||||||
|
: DateUtils.getBusinessLocalDate();
|
||||||
|
interBranchCashService.moveCashLegToBranch(savingsTxnId, accountId, counterId, String.valueOf(record.get("account_no")), amount,
|
||||||
|
date, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows whose branch (the counter that took the cash; the cashier's own office for requests raised before that was
|
||||||
|
* recorded) is at or below the caller's office. Takes the caller's hierarchy plus "%" as its one parameter.
|
||||||
|
*/
|
||||||
|
private static final String REQUEST_BRANCH_IN_HIERARCHY = "COALESCE(counter_office_id, "
|
||||||
|
+ "(SELECT au.office_id FROM m_appuser au WHERE au.id = cashier_id)) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)";
|
||||||
|
|
||||||
|
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
|
||||||
|
private void assertRequestInCallersBranch(final Map<?, ?> record) {
|
||||||
|
final Integer visible = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_cashier_deposit_request WHERE id = ? AND " + REQUEST_BRANCH_IN_HIERARCHY, Integer.class,
|
||||||
|
((Number) record.get("id")).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
|
||||||
|
if (visible == null || visible == 0) {
|
||||||
|
throw new NotFoundException("Deposit request not found: " + record.get("id"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ──
|
// ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ──
|
||||||
@@ -400,6 +481,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record);
|
||||||
|
|
||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||||
@@ -457,6 +539,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
|
||||||
};
|
};
|
||||||
if (!isCurrentAccount && txnId != null) {
|
if (!isCurrentAccount && txnId != null) {
|
||||||
|
moveCashToCounterBranch(record, txnId, amount, txnDateStr);
|
||||||
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
// approve() is one @Transactional block, so an uncaught exception here would roll back
|
||||||
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
|
||||||
// hit an edge case in the new hold-placement logic would fail entirely, where before
|
// hit an edge case in the new hold-placement logic would fail entirely, where before
|
||||||
@@ -537,6 +620,7 @@ public class CashierDepositRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record);
|
||||||
|
|
||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
|
||||||
|
|||||||
+19
-5
@@ -577,11 +577,11 @@ public class ShareAdvanceCollectionApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
|
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
|
||||||
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
|
// receipt into whatever GL account the cashier actually deposited it to. This id proves the
|
||||||
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
|
// fee was paid and never contributes to the share-value leg below. If the cashier banked it
|
||||||
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
|
// into an income account it is left there; anywhere else (Sundry, most often) it is moved to
|
||||||
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
|
// Share Entry Fee Income below. Omit it (leave null) when the fee was paid as part of the same
|
||||||
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
|
// Sundry receipt(s) as the share value -- that single-receipt case is swept out of Sundry below.
|
||||||
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
|
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
|
||||||
|
|
||||||
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
|
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
|
||||||
@@ -748,6 +748,20 @@ public class ShareAdvanceCollectionApiResource {
|
|||||||
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
|
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
|
||||||
currencyCode, "Share entry fee for " + accountNo);
|
currencyCode, "Share entry fee for " + accountNo);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// A separate fee receipt is only already income if the cashier banked it into an income
|
||||||
|
// account. One deposited into Sundry (or any other non-income account) sat there for
|
||||||
|
// good: nothing here moved it, so the year-end close never swept it into profit. Move
|
||||||
|
// the fee out of wherever it actually sits; any over-payment on that receipt stays put,
|
||||||
|
// as before.
|
||||||
|
final Long feeReceiptGl = receiptGlOf((String) chargeColl.get("gl_transaction_id"), glSuspense);
|
||||||
|
if (!isIncomeGl(feeReceiptGl)) {
|
||||||
|
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
|
||||||
|
insertEntry(officeId, feeReceiptGl, DEBIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||||
|
"Share entry fee settled | Receipt " + chargeColl.get("receipt_no") + " | " + accountNo);
|
||||||
|
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
|
||||||
|
"Share entry fee for " + accountNo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
|
||||||
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
|
||||||
|
|||||||
-331
@@ -1,331 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-496
@@ -1,496 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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.I4cAdminReadService;
|
|
||||||
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.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 final PlatformSecurityContext context;
|
|
||||||
private final I4cAdminReadService readService;
|
|
||||||
private final I4cGrievanceService grievances;
|
|
||||||
private final I4cRestorationService restorations;
|
|
||||||
private final I4cDocumentService documents;
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 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
@@ -1,308 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-180
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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 {
|
|
||||||
|
|
||||||
/** Bank-owned savings account outward restorations leave from; empty = restorations within this bank only. */
|
|
||||||
private String poolAccountNo;
|
|
||||||
/** MRM manual: above this amount in a single complaint the IO must give an FIR. */
|
|
||||||
private java.math.BigDecimal firThreshold = new java.math.BigDecimal("50000");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public static class Simulator {
|
|
||||||
|
|
||||||
private boolean enabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-133
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,271 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-128
@@ -1,128 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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.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.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;
|
|
||||||
|
|
||||||
@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();
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-270
@@ -1,270 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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, and the current-account block does
|
|
||||||
* not work at all. So the amount hold is the primary control and a debit freeze is only an extra, savings-only step an
|
|
||||||
* officer can ask for.
|
|
||||||
*
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
requireSavings(account, "Debit freeze");
|
|
||||||
final JsonObject json = new JsonObject();
|
|
||||||
json.addProperty("reasonForBlock", truncate(reason, 100));
|
|
||||||
savingsWriteService.blockDebits(account.id(), JsonCommand.fromJsonElement(account.id(), json, fromJsonHelper));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void unfreezeDebits(final AccountRef account) {
|
|
||||||
requireSavings(account, "Debit unfreeze");
|
|
||||||
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 void requireSavings(final AccountRef account, final String what) {
|
|
||||||
if (!account.isSavings()) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
what + " is available only on savings accounts; on a current account the amount hold" + " is the control that works.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BigDecimal nonNegative(final BigDecimal value) {
|
|
||||||
return value == null || value.signum() < 0 ? BigDecimal.ZERO : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-578
@@ -1,578 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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;
|
|
||||||
|
|
||||||
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"));
|
|
||||||
// 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));
|
|
||||||
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 search} (ack no., job id) and the date queued. */
|
|
||||||
public Map<String, Object> outbox(final I4cListQuery q) {
|
|
||||||
final StringBuilder sql = new StringBuilder("""
|
|
||||||
SELECT * FROM (
|
|
||||||
SELECT o.id, o.request_id, r.acknowledgement_no, r.job_id, o.message_type, o.status, o.attempts, o.next_attempt_at,
|
|
||||||
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"));
|
|
||||||
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 search} (endpoint, ack no., caller IP) and the date of the call.
|
|
||||||
*/
|
|
||||||
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"));
|
|
||||||
like(sql, params, q.text("search"), "x.endpoint", "x.acknowledgement_no", "x.client_ip");
|
|
||||||
dateRange(sql, params, "x.created_at", q);
|
|
||||||
return page(sql.toString(), params, "x.id DESC", q);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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, 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"));
|
|
||||||
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("poolConfigured", notBlank(properties.getRestoration().getPoolAccountNo()));
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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();
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-263
@@ -1,263 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-401
@@ -1,401 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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())
|
|
||||||
: "Disputed amount put on hold";
|
|
||||||
entries.add(I4cResponseBuilder.putOnHold(root, hold.amountHeld(), now, remarks));
|
|
||||||
}
|
|
||||||
if (hold.shortfall().signum() > 0) {
|
|
||||||
BigDecimal untraced = hold.shortfall();
|
|
||||||
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, "No balance to hold; prospective hold of Rs "
|
|
||||||
+ I4cResponseBuilder.money(hold.shortfall()) + " placed on future credits"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
saveFindings(requestId, incidentId, entries);
|
|
||||||
final String outcome = !hold.holdable() ? "NOT_HOLDABLE"
|
|
||||||
: hold.shortfall().signum() == 0 ? "HELD" : hold.amountHeld().signum() > 0 ? "PARTIALLY_HELD" : "PROSPECTIVE_HOLD";
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-176
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-391
@@ -1,391 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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 -> (GR id from GRM, KYC / application / photo / statement) -> NODAL_REVIEW
|
|
||||||
* -> nodal approves -> WITH_IO (7 days) -> [IO investigates: IO_INVESTIGATION, 15 days]
|
|
||||||
* -> 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",
|
|
||||||
"REJECTED_CONTINUE", "SEEK_INFO");
|
|
||||||
public static final Set<String> NOTE_EVENTS = Set.of("VC_REQUESTED", "VC_DONE", "VC_NOT_REQUIRED", "INFO_SOUGHT", "INFO_SENT", "NOTE");
|
|
||||||
|
|
||||||
static final Duration IO_WINDOW = Duration.ofDays(7);
|
|
||||||
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;
|
|
||||||
|
|
||||||
@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, created_by, created_at,
|
|
||||||
updated_at)
|
|
||||||
VALUES (?,?,?,'DRAFT','OPEN',?,?,?,?)
|
|
||||||
RETURNING id
|
|
||||||
""", Long.class, holdId, request, trim(customerReason, 2000), now, 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);
|
|
||||||
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 ("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 "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);
|
|
||||||
if (!"REJECTED".equals(g.get("status")) || "CLOSED".equals(g.get("stage"))) {
|
|
||||||
throw error("grievance.escalate", "Only a grievance rejected by the IO, DGRO or SGRO can be escalated.");
|
|
||||||
}
|
|
||||||
final String stage = (String) g.get("stage");
|
|
||||||
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)
|
|
||||||
: "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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-491
@@ -1,491 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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";
|
|
||||||
public static final Set<String> ACTIONS = Set.of(ACTION_RELEASE, ACTION_FREEZE, ACTION_UNFREEZE, ACTION_UNFREEZE_AND_RELEASE,
|
|
||||||
ACTION_RESTORE);
|
|
||||||
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;
|
|
||||||
|
|
||||||
/** What was achieved for one incident: amount now held, what is still short, and the register row. */
|
|
||||||
public record HoldOutcome(Long holdId, BigDecimal amountHeld, BigDecimal shortfall, boolean holdable) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
if (existing.isPresent()) {
|
|
||||||
final Map<String, Object> h = existing.get();
|
|
||||||
return new HoldOutcome(((Number) h.get("id")).longValue(), (BigDecimal) h.get("amount_held"), (BigDecimal) h.get("shortfall"),
|
|
||||||
!NOT_HOLDABLE.equals(h.get("status")));
|
|
||||||
}
|
|
||||||
final 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 shortfall = account.holdable() ? disputedAmount.subtract(held).max(BigDecimal.ZERO) : disputedAmount;
|
|
||||||
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);
|
|
||||||
return new HoldOutcome(holdId, held, shortfall, account.holdable());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 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_RELEASE.equals(act) || ACTION_UNFREEZE_AND_RELEASE.equals(act) || ACTION_RESTORE.equals(act))
|
|
||||||
&& (orderReference == null || orderReference.isBlank()) && !"REVIEW_90_DAYS".equals(reason)) {
|
|
||||||
// A release takes money out of the fraud trail; it rests on an order, except at the 90-day review.
|
|
||||||
throw error("order.required", "An order / reference number is required to release a hold.");
|
|
||||||
}
|
|
||||||
if ((ACTION_FREEZE.equals(act) || ACTION_UNFREEZE.equals(act)) && !I4cAccountGateway.SAVINGS.equals(hold.get("account_type"))) {
|
|
||||||
throw error("freeze.savings.only",
|
|
||||||
"Debit freeze is available only on savings accounts; the amount hold is the control on" + " current accounts.");
|
|
||||||
}
|
|
||||||
if (ACTION_FREEZE.equals(act) && Boolean.TRUE.equals(hold.get("debit_frozen"))) {
|
|
||||||
throw error("already.frozen", "Debits on this account are already frozen for this hold.");
|
|
||||||
}
|
|
||||||
if (ACTION_UNFREEZE.equals(act) && !Boolean.TRUE.equals(hold.get("debit_frozen"))) {
|
|
||||||
throw error("not.frozen", "Debits on this account are not frozen for this hold.");
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
@@ -1,288 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-355
@@ -1,355 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
@@ -1,125 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-123
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
|
|
||||||
|
|
||||||
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.domain.ExternalId;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|
||||||
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.AccountTransfersWritePlatformService;
|
|
||||||
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
|
|
||||||
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAccountGateway.AccountRef;
|
|
||||||
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
|
|
||||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyRequestData;
|
|
||||||
import org.apache.fineract.portfolio.fundtransfer.data.VerifyResponseData;
|
|
||||||
import org.apache.fineract.portfolio.fundtransfer.service.BankIfscLookupService;
|
|
||||||
import org.apache.fineract.portfolio.fundtransfer.service.FundTransferWritePlatformService;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves restored money: account to account inside this bank (savings or current, either side), and outward from the
|
|
||||||
* restoration pool through the CBS fund-transfer module (NEFT / IMPS / RTGS, with that module's own limits, time windows,
|
|
||||||
* screening and approval). Every call runs as the signed-in officer, in the caller's transaction.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class I4cMoneyMover {
|
|
||||||
|
|
||||||
private final AccountTransfersWritePlatformService transfersService;
|
|
||||||
private final FundTransferWritePlatformService fundTransferService;
|
|
||||||
private final BankIfscLookupService ifscLookup;
|
|
||||||
private final I4cAccountGateway gateway;
|
|
||||||
private final I4cProperties properties;
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
/** 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) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the IFSC is blank or belongs to this bank, i.e. the victim's account should be here. */
|
|
||||||
public boolean isOurs(final String ifsc) {
|
|
||||||
return ifsc == null || ifsc.isBlank() || ifscLookup.isOurIfsc(ifsc.trim().toUpperCase(java.util.Locale.ROOT));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The restoration pool account, when configured and usable for an outward transfer. */
|
|
||||||
public Optional<AccountRef> pool() {
|
|
||||||
final String number = properties.getRestoration().getPoolAccountNo();
|
|
||||||
if (number == null || number.isBlank()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return gateway.resolve(number.trim()).filter(a -> a.isSavings() && a.status() == 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends money from the pool to an account in another bank. The fund-transfer module debits the pool when the
|
|
||||||
* transfer runs, which is now or when its checker approves it; it returns the module's reference and state.
|
|
||||||
*/
|
|
||||||
public TransferResponseData outwardFromPool(final AccountRef pool, final String channel, final String payeeAccountNo,
|
|
||||||
final String payeeIfsc, final String payeeName, final BigDecimal amount, final String remarks) {
|
|
||||||
final VerifyRequestData request = new VerifyRequestData();
|
|
||||||
request.setChannel(channel);
|
|
||||||
request.setPayerAccountId(pool.id());
|
|
||||||
request.setPayeeAccountNumber(payeeAccountNo);
|
|
||||||
request.setPayeeIfsc(payeeIfsc);
|
|
||||||
request.setPayeeName(payeeName);
|
|
||||||
request.setAmount(amount.setScale(2, RoundingMode.DOWN));
|
|
||||||
final VerifyResponseData verified = fundTransferService.verify(request);
|
|
||||||
return fundTransferService.createFromToken(verified.getVerificationToken(), truncate(remarks, 200));
|
|
||||||
}
|
|
||||||
|
|
||||||
public 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 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
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,131 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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
@@ -1,259 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-221
@@ -1,221 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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: the money first moves from the suspect account to the restoration pool, in the
|
|
||||||
* approval's transaction (IN_POOL), then goes out from the pool through the fund-transfer module (TRANSFER_PENDING
|
|
||||||
* until it settles). A refused or failed outward leaves it in the pool for a retry, possibly to corrected victim
|
|
||||||
* details (MRM "refer back to victim bank"); it never returns to the suspect.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@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 = ?, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""", RESTORED, transferId, "CBS-" + transferId, amount, now, now, restorationId);
|
|
||||||
log.info("I4C restoration {}: {} restored from {} to {} in this bank (transfer {})", restorationId, amount, suspect.accountNo(),
|
|
||||||
victimAccount, transferId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final AccountRef pool = mover.pool().orElseThrow(() -> new IllegalStateException(
|
|
||||||
"The victim's account is in another bank and no restoration pool account is configured (fineract.i4c.restoration.pool-account-no)"));
|
|
||||||
final Long poolTransferId = mover.transfer(suspect, pool, amount, description);
|
|
||||||
jdbcTemplate.update("""
|
|
||||||
UPDATE m_i4c_restoration SET status = ?, pool_transfer_id = ?, restored_amount = ?, transfer_error = NULL, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""", IN_POOL, poolTransferId, amount, now, restorationId);
|
|
||||||
log.info("I4C restoration {}: {} moved from {} to the restoration pool {} (transfer {})", restorationId, amount, suspect.accountNo(),
|
|
||||||
pool.accountNo(), poolTransferId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** After the approval committed: send a pooled restoration out. 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 AccountRef pool = mover.pool()
|
|
||||||
.orElseThrow(() -> new IllegalStateException("No restoration pool account is configured"));
|
|
||||||
final String channel = r.get("transfer_channel") == null ? "NEFT" : (String) r.get("transfer_channel");
|
|
||||||
final TransferResponseData sent = mover.outwardFromPool(pool, channel, (String) r.get("victim_account_no"),
|
|
||||||
(String) r.get("victim_ifsc"), (String) r.get("victim_name"), (BigDecimal) r.get("restored_amount"),
|
|
||||||
"I4C MRM restoration Ack " + r.get("acknowledgement_no"));
|
|
||||||
final 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, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""", next, failed ? null : sent.getTxnRef(), state, sent.getUtr(),
|
|
||||||
failed ? trim("Outward " + channel + " failed: " + sent.getResponseMessage(), 1000) : null, settled, now, now,
|
|
||||||
restorationId);
|
|
||||||
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 pool. 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 = ?, updated_at = ?
|
|
||||||
WHERE id = ? AND status = ?
|
|
||||||
""", RESTORED, s, state.get().utr(), now, now, row.get("id"), TRANSFER_PENDING);
|
|
||||||
} else if ("FAILED".equals(s) || "REVERSED".equals(s)) {
|
|
||||||
changed += jdbcTemplate.update("""
|
|
||||||
UPDATE m_i4c_restoration SET status = ?, transfer_status = ?, fund_transfer_ref = NULL, transfer_error = ?, updated_at = ?
|
|
||||||
WHERE id = ? AND status = ?
|
|
||||||
""", IN_POOL, s, trim("Outward transfer " + row.get("fund_transfer_ref") + " " + s + ": " + state.get().responseMessage()
|
|
||||||
+ "; the money is back in the restoration pool", 1000), now, row.get("id"), TRANSFER_PENDING);
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-545
@@ -1,545 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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");
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 stuck in the pool (window closed, switch refused, account corrected): send it again. */
|
|
||||||
public String retryOutward(final Long id) {
|
|
||||||
final Map<String, Object> r = loadBeneficiaryPlain(id);
|
|
||||||
if (!IN_POOL.equals(r.get("status"))) {
|
|
||||||
throw error("restoration.not.pooled", "Only a restoration waiting in the pool can be sent again.");
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-94
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-361
@@ -1,361 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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.ZoneOffset;
|
|
||||||
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 in UTC (created_on_utc), current
|
|
||||||
* rows in local time (created_date); the business date is kept and only the time of day is taken from the stamp.
|
|
||||||
*/
|
|
||||||
static String dateTime(final Object transactionDate, final Object createdAt) {
|
|
||||||
final LocalDate date = toLocalDate(transactionDate);
|
|
||||||
if (createdAt instanceof Timestamp ts) {
|
|
||||||
final LocalDateTime created = ts.toLocalDateTime();
|
|
||||||
final LocalDateTime local = created.atOffset(ZoneOffset.UTC).atZoneSameInstant(tenantZone()).toLocalDateTime();
|
|
||||||
final LocalDateTime chosen = local.toLocalDate().equals(date) ? local : created;
|
|
||||||
return (date == null ? chosen : date.atTime(chosen.toLocalTime())).withNano(0).format(DATE_TIME);
|
|
||||||
}
|
|
||||||
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
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-133
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.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.I4cComplaintParser;
|
|
||||||
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.</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;
|
|
||||||
|
|
||||||
@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 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);
|
|
||||||
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());
|
|
||||||
return ok(out.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-1
@@ -239,7 +239,11 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
|
|
||||||
final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId)
|
final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId)
|
||||||
: getCurrentControlGlId(accountId);
|
: getCurrentControlGlId(accountId);
|
||||||
final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId() : null;
|
// A charge that names no income GL of its own falls back to the product's Income From Fees, the
|
||||||
|
// same order the savings charge bridge follows. Facility charges are seeded with no GL, so without
|
||||||
|
// this every cheque book was issued free and the fee never reached income or the year's profit.
|
||||||
|
final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId()
|
||||||
|
: getProductFeeIncomeGlId(accountType, accountId);
|
||||||
if (ownAccountGlId == null || incomeGlId == null) {
|
if (ownAccountGlId == null || incomeGlId == null) {
|
||||||
log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); "
|
log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); "
|
||||||
+ "charge deduction skipped to avoid an unbalanced ledger.", chequeBookId, ownAccountGlId, incomeGlId);
|
+ "charge deduction skipped to avoid an unbalanced ledger.", chequeBookId, ownAccountGlId, incomeGlId);
|
||||||
@@ -345,6 +349,19 @@ public class ChequeBookIssuanceChargeService {
|
|||||||
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The account's product's Income From Fees mapping (financial_account_type 4), or null if the product maps none. */
|
||||||
|
private Long getProductFeeIncomeGlId(final ChequeAccountType accountType, final Long accountId) {
|
||||||
|
final String sql = accountType == ChequeAccountType.SAVINGS
|
||||||
|
? "SELECT apm.gl_account_id FROM m_savings_account sa JOIN acc_product_mapping apm ON apm.product_id = sa.product_id "
|
||||||
|
+ "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL "
|
||||||
|
+ "AND apm.payment_type IS NULL LIMIT 1"
|
||||||
|
: "SELECT apm.gl_account_id FROM m_current_account ca JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
|
||||||
|
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL "
|
||||||
|
+ "AND apm.payment_type IS NULL LIMIT 1";
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, accountId);
|
||||||
|
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
|
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
|
||||||
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
|
||||||
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
|
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
|
||||||
|
|||||||
+319
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* 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.cic.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.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DefaultValue;
|
||||||
|
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.MediaType;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.cic.service.CicAlertService;
|
||||||
|
import org.apache.fineract.portfolio.cic.service.CicCatalog;
|
||||||
|
import org.apache.fineract.portfolio.cic.service.CicConfigService;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CIC / credit bureau: bureau whitelist and configuration, API mapping, and the enquiry alerts the CICs send when one of
|
||||||
|
* the bank's customers is enquired by another lender.
|
||||||
|
*
|
||||||
|
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICALERT (dashboard and register),
|
||||||
|
* UPDATE_CICALERT (work an alert), FETCH_CICALERT (fetch now / import a file).
|
||||||
|
*/
|
||||||
|
@Path("/v1/cic")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping and enquiry alerts.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final CicConfigService configService;
|
||||||
|
private final CicAlertService alertService;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
// ── Catalog ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("catalog")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String catalog() {
|
||||||
|
context.authenticatedUser();
|
||||||
|
final Map<String, Object> c = new LinkedHashMap<>();
|
||||||
|
c.put("environments", CicCatalog.ENVIRONMENTS);
|
||||||
|
c.put("authTypes", CicCatalog.AUTH_TYPES);
|
||||||
|
c.put("endpointTypes", CicCatalog.ENDPOINT_TYPES);
|
||||||
|
c.put("httpMethods", CicCatalog.HTTP_METHODS);
|
||||||
|
c.put("cbsFields", CicCatalog.CBS_FIELDS);
|
||||||
|
c.put("resultFields", CicCatalog.RESULT_FIELDS);
|
||||||
|
c.put("rootFields", CicCatalog.ROOT_FIELDS);
|
||||||
|
c.put("transforms", CicCatalog.TRANSFORMS);
|
||||||
|
c.put("alertStatuses", CicCatalog.ALERT_STATUSES);
|
||||||
|
c.put("matchTypes", CicCatalog.MATCH_TYPES);
|
||||||
|
return gson.toJson(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bureaus ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("bureaus")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String bureaus(@QueryParam("whitelistedOnly") @DefaultValue("false") final boolean whitelistedOnly) {
|
||||||
|
requireAny("READ_CICBUREAU", "READ_CICALERT");
|
||||||
|
return gson.toJson(configService.listBureaus(whitelistedOnly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("bureaus/{id}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String bureau(@PathParam("id") final Long id) {
|
||||||
|
require("READ_CICBUREAU");
|
||||||
|
return gson.toJson(configService.getBureau(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("bureaus/{id}")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String updateBureau(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICBUREAU");
|
||||||
|
return gson.toJson(configService.updateBureau(id, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("bureaus/{id}/whitelist")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String whitelist(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICBUREAU");
|
||||||
|
final JsonObject in = json(body);
|
||||||
|
final boolean whitelisted = in.has("whitelisted") && !in.get("whitelisted").isJsonNull() && in.get("whitelisted").getAsBoolean();
|
||||||
|
return gson.toJson(configService.setWhitelisted(id, whitelisted, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/test-connection")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String testConnection(@PathParam("id") final Long id) {
|
||||||
|
require("UPDATE_CICBUREAU");
|
||||||
|
return gson.toJson(configService.testConnection(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API mapping ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("bureaus/{id}/endpoints/{type}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String endpoint(@PathParam("id") final Long id, @PathParam("type") final String type) {
|
||||||
|
require("READ_CICBUREAU");
|
||||||
|
return gson.toJson(configService.getEndpoint(id, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("bureaus/{id}/endpoints/{type}")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String saveEndpoint(@PathParam("id") final Long id, @PathParam("type") final String type, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICBUREAU");
|
||||||
|
return gson.toJson(configService.saveEndpoint(id, type, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/preview")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String preview(@PathParam("id") final Long id, final String body) {
|
||||||
|
require("READ_CICBUREAU");
|
||||||
|
return gson.toJson(alertService.preview(id, json(body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/parse-test")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String parseTest(@PathParam("id") final Long id, final String body) {
|
||||||
|
require("READ_CICBUREAU");
|
||||||
|
return gson.toJson(alertService.parseTest(id, json(body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch / import ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/fetch")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String fetch(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("FETCH_CICALERT");
|
||||||
|
final JsonObject in = json(body);
|
||||||
|
return gson.toJson(alertService.fetch(id, text(in, "fromDate"), text(in, "toDate"), "MANUAL", user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/import")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String importFile(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("FETCH_CICALERT");
|
||||||
|
return gson.toJson(alertService.importFile(id, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("fetch-runs")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String fetchRuns(@QueryParam("bureauId") final Long bureauId, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||||
|
@QueryParam("limit") @DefaultValue("20") final int limit) {
|
||||||
|
requireAny("READ_CICALERT", "READ_CICBUREAU");
|
||||||
|
return gson.toJson(alertService.fetchRuns(bureauId, Math.max(0, offset), clamp(limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("fetch-runs/{id}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String fetchRun(@PathParam("id") final Long id) {
|
||||||
|
requireAny("READ_CICALERT", "READ_CICBUREAU");
|
||||||
|
return gson.toJson(alertService.fetchRun(id, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alerts ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("dashboard")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String dashboard(@QueryParam("days") @DefaultValue("30") final int days) {
|
||||||
|
require("READ_CICALERT");
|
||||||
|
return gson.toJson(alertService.dashboard(Math.max(1, Math.min(365, days))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("alerts")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String alerts(@QueryParam("status") final String status, @QueryParam("match") final String match,
|
||||||
|
@QueryParam("clientId") final String clientId, @QueryParam("bureauId") final String bureauId,
|
||||||
|
@QueryParam("officeId") final String officeId, @QueryParam("loanType") final String loanType,
|
||||||
|
@QueryParam("institutionType") final String institutionType, @QueryParam("fromDate") final String fromDate,
|
||||||
|
@QueryParam("toDate") final String toDate, @QueryParam("minAmount") final String minAmount,
|
||||||
|
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||||
|
@QueryParam("limit") @DefaultValue("20") final int limit) {
|
||||||
|
require("READ_CICALERT");
|
||||||
|
final JsonObject f = new JsonObject();
|
||||||
|
f.addProperty("status", status);
|
||||||
|
f.addProperty("match", match);
|
||||||
|
f.addProperty("clientId", clientId);
|
||||||
|
f.addProperty("bureauId", bureauId);
|
||||||
|
f.addProperty("officeId", officeId);
|
||||||
|
f.addProperty("loanType", loanType);
|
||||||
|
f.addProperty("institutionType", institutionType);
|
||||||
|
f.addProperty("fromDate", fromDate);
|
||||||
|
f.addProperty("toDate", toDate);
|
||||||
|
f.addProperty("minAmount", minAmount);
|
||||||
|
f.addProperty("search", search);
|
||||||
|
return gson.toJson(alertService.list(f, Math.max(0, offset), Math.max(1, Math.min(5000, limit))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("alerts/options")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String alertOptions() {
|
||||||
|
require("READ_CICALERT");
|
||||||
|
return gson.toJson(alertService.filterOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("alerts/{id}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String alert(@PathParam("id") final Long id) {
|
||||||
|
require("READ_CICALERT");
|
||||||
|
return gson.toJson(alertService.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("alerts/{id}/status")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String updateStatus(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICALERT");
|
||||||
|
return gson.toJson(alertService.updateStatus(id, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("alerts/{id}/client")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String linkClient(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICALERT");
|
||||||
|
return gson.toJson(alertService.linkClient(id, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("alerts/mark-seen")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String markSeen(final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICALERT");
|
||||||
|
return gson.toJson(alertService.markSeen(json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private AppUser require(final String permission) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo(permission);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUser requireAny(final String... permissions) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
if (user.hasAnyPermission("ALL_FUNCTIONS") || user.hasAnyPermission(permissions)) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
throw new NoAuthorizationException("User has no authority to " + String.join(" / ", permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject json(final String body) {
|
||||||
|
if (StringUtils.isBlank(body)) {
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
final JsonElement e = JsonParser.parseString(body);
|
||||||
|
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(final JsonObject in, final String key) {
|
||||||
|
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(final int limit) {
|
||||||
|
return Math.max(1, Math.min(200, limit));
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 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.cic.service;
|
||||||
|
|
||||||
|
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.businessdate.service.BusinessDateReadPlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||||
|
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.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches enquiry alerts automatically: every few minutes, for every tenant, each whitelisted bureau with automatic
|
||||||
|
* fetch on is fetched once its interval has passed since the last fetch. Runs only on the batch-manager node.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicAlertScheduler {
|
||||||
|
|
||||||
|
private final FineractProperties fineractProperties;
|
||||||
|
private final TenantDetailsService tenantDetailsService;
|
||||||
|
private final BusinessDateReadPlatformService businessDateReadPlatformService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final CicAlertService alertService;
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${cbs.cic.alert-scheduler-delay-ms:300000}", initialDelayString = "${cbs.cic.alert-scheduler-initial-delay-ms:180000}")
|
||||||
|
public void fetchDueAlerts() {
|
||||||
|
if (!fineractProperties.getMode().isBatchManagerEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
|
||||||
|
try {
|
||||||
|
ThreadLocalContextUtil.setTenant(tenant);
|
||||||
|
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
|
||||||
|
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
|
||||||
|
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, code, alert_fetch_interval_min, last_fetch_on FROM m_cic_bureau
|
||||||
|
WHERE whitelisted = TRUE AND alert_auto_fetch = TRUE
|
||||||
|
""");
|
||||||
|
final long now = Instant.now().toEpochMilli();
|
||||||
|
for (final Map<String, Object> b : due) {
|
||||||
|
final long intervalMs = ((Number) b.get("alert_fetch_interval_min")).longValue() * 60_000L;
|
||||||
|
final Object last = b.get("last_fetch_on");
|
||||||
|
if (last instanceof Timestamp ts && now - ts.getTime() < intervalMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
alertService.fetch(((Number) b.get("id")).longValue(), null, null, "SCHEDULED", null);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// A bureau that is not ready (no URL, no credential ...) is reported on the run / bureau, not retried here.
|
||||||
|
log.warn("CIC scheduled alert fetch of {} for tenant {} failed: {}", b.get("code"), tenant.getTenantIdentifier(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// A tenant whose database does not have the CIC tables yet.
|
||||||
|
log.debug("CIC alert scheduler skipped tenant {}: {}", tenant.getTenantIdentifier(), e.getMessage());
|
||||||
|
} finally {
|
||||||
|
ThreadLocalContextUtil.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1041
File diff suppressed because it is too large
Load Diff
+215
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* 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.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonPrimitive;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the API Mapping screen can choose from for the ENQUIRY_ALERT call, and the transforms applied when a
|
||||||
|
* value is written into the bureau request or read back from the bureau response.
|
||||||
|
*/
|
||||||
|
public final class CicCatalog {
|
||||||
|
|
||||||
|
private CicCatalog() {}
|
||||||
|
|
||||||
|
public static final List<String> ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION");
|
||||||
|
public static final List<String> AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS");
|
||||||
|
public static final String ALERT_ENDPOINT = "ENQUIRY_ALERT";
|
||||||
|
public static final List<String> ENDPOINT_TYPES = List.of(ALERT_ENDPOINT);
|
||||||
|
public static final List<String> HTTP_METHODS = List.of("POST", "PUT", "GET");
|
||||||
|
public static final List<String> ALERT_STATUSES = List.of("NEW", "SEEN", "ACTIONED", "DISMISSED");
|
||||||
|
public static final List<String> MATCH_TYPES = List.of("PAN", "AADHAAR", "CUSTOMER_REF", "MANUAL", "NONE");
|
||||||
|
|
||||||
|
/** Values a REQUEST mapping can take: the fetch window and the bank's identity at the bureau. */
|
||||||
|
public static final Map<String, String> CBS_FIELDS = new LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
CBS_FIELDS.put("fetch.reference", "Fetch reference number");
|
||||||
|
CBS_FIELDS.put("fetch.fromDate", "Alerts from date");
|
||||||
|
CBS_FIELDS.put("fetch.toDate", "Alerts to date");
|
||||||
|
CBS_FIELDS.put("fetch.date", "Request date (business date)");
|
||||||
|
CBS_FIELDS.put("bureau.memberCode", "Bank's member code at the bureau");
|
||||||
|
CBS_FIELDS.put("bureau.username", "Bureau user id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result fields a RESPONSE mapping can fill. errorCode / errorMessage / alerts are read from the root of the
|
||||||
|
* response; "alerts" points at the list, and every other field is read from each item of that list.
|
||||||
|
*/
|
||||||
|
public static final Map<String, String> RESULT_FIELDS = new LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
RESULT_FIELDS.put("alerts", "List of alerts (array) - root");
|
||||||
|
RESULT_FIELDS.put("errorCode", "Bureau error code - root");
|
||||||
|
RESULT_FIELDS.put("errorMessage", "Bureau error message - root");
|
||||||
|
RESULT_FIELDS.put("alertRef", "Alert / enquiry id at the bureau");
|
||||||
|
RESULT_FIELDS.put("pan", "Customer PAN");
|
||||||
|
RESULT_FIELDS.put("aadhaar", "Customer Aadhaar (masked or last 4)");
|
||||||
|
RESULT_FIELDS.put("customerName", "Customer name");
|
||||||
|
RESULT_FIELDS.put("dateOfBirth", "Customer date of birth");
|
||||||
|
RESULT_FIELDS.put("customerRef", "Our customer reference (CIF / account no) reported to the bureau");
|
||||||
|
RESULT_FIELDS.put("institutionCode", "Enquiring lender's member code");
|
||||||
|
RESULT_FIELDS.put("institutionName", "Enquiring lender (other bank / NBFC)");
|
||||||
|
RESULT_FIELDS.put("institutionType", "Lender type (Bank / NBFC / MFI ...)");
|
||||||
|
RESULT_FIELDS.put("enquiryDate", "Enquiry date");
|
||||||
|
RESULT_FIELDS.put("loanType", "Loan type applied for");
|
||||||
|
RESULT_FIELDS.put("amount", "Loan amount applied for");
|
||||||
|
RESULT_FIELDS.put("purpose", "Enquiry purpose");
|
||||||
|
RESULT_FIELDS.put("location", "Branch / city of the enquiry");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result fields read from the root of the response, not from each alert. */
|
||||||
|
public static final Set<String> ROOT_FIELDS = Set.of("alerts", "errorCode", "errorMessage");
|
||||||
|
|
||||||
|
public static final Map<String, String> TRANSFORMS = new LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
TRANSFORMS.put("NONE", "As is");
|
||||||
|
TRANSFORMS.put("UPPER", "UPPER CASE");
|
||||||
|
TRANSFORMS.put("LOWER", "lower case");
|
||||||
|
TRANSFORMS.put("TRIM", "Trim spaces");
|
||||||
|
TRANSFORMS.put("DIGITS_ONLY", "Digits only");
|
||||||
|
TRANSFORMS.put("DATE_DDMMYYYY", "Date ddMMyyyy");
|
||||||
|
TRANSFORMS.put("DATE_DD_MM_YYYY", "Date dd-MM-yyyy");
|
||||||
|
TRANSFORMS.put("DATE_DD_SLASH_MM_YYYY", "Date dd/MM/yyyy");
|
||||||
|
TRANSFORMS.put("DATE_YYYY_MM_DD", "Date yyyy-MM-dd");
|
||||||
|
TRANSFORMS.put("AMOUNT_INTEGER", "Amount, whole rupees");
|
||||||
|
TRANSFORMS.put("NUMBER", "Number");
|
||||||
|
TRANSFORMS.put("BOOLEAN", "true / false");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<String, String> DATE_PATTERNS = Map.of("DATE_DDMMYYYY", "ddMMyyyy", "DATE_DD_MM_YYYY", "dd-MM-yyyy",
|
||||||
|
"DATE_DD_SLASH_MM_YYYY", "dd/MM/yyyy", "DATE_YYYY_MM_DD", "yyyy-MM-dd");
|
||||||
|
|
||||||
|
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||||
|
|
||||||
|
/** Applies a REQUEST transform to a CBS value; null stays null. Dates come in as ISO yyyy-MM-dd. */
|
||||||
|
public static JsonElement applyRequestTransform(final String transform, final Object raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
|
||||||
|
final String s = String.valueOf(raw).trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (DATE_PATTERNS.containsKey(t)) {
|
||||||
|
try {
|
||||||
|
return new JsonPrimitive(LocalDate.parse(s.length() > 10 ? s.substring(0, 10) : s, ISO)
|
||||||
|
.format(DateTimeFormatter.ofPattern(DATE_PATTERNS.get(t))));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return new JsonPrimitive(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (t) {
|
||||||
|
case "UPPER":
|
||||||
|
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
|
||||||
|
case "LOWER":
|
||||||
|
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
|
||||||
|
case "TRIM":
|
||||||
|
return new JsonPrimitive(s.replaceAll("\\s+", " "));
|
||||||
|
case "DIGITS_ONLY": {
|
||||||
|
final String digits = s.replaceAll("\\D", "");
|
||||||
|
return digits.isEmpty() ? null : new JsonPrimitive(digits);
|
||||||
|
}
|
||||||
|
case "AMOUNT_INTEGER":
|
||||||
|
return new JsonPrimitive(new BigDecimal(s).setScale(0, RoundingMode.HALF_UP).toBigInteger());
|
||||||
|
case "NUMBER":
|
||||||
|
return new JsonPrimitive(new BigDecimal(s).stripTrailingZeros());
|
||||||
|
case "BOOLEAN":
|
||||||
|
return new JsonPrimitive(Boolean.parseBoolean(s) || "1".equals(s) || "Y".equalsIgnoreCase(s));
|
||||||
|
default:
|
||||||
|
return new JsonPrimitive(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a value read from a bureau response. A DATE_* transform names the format the bureau sends, and the
|
||||||
|
* value comes out as ISO yyyy-MM-dd. Arrays and objects pass through.
|
||||||
|
*/
|
||||||
|
public static JsonElement applyResponseTransform(final String transform, final JsonElement value) {
|
||||||
|
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
|
||||||
|
final String s = value.getAsString().trim();
|
||||||
|
try {
|
||||||
|
if (DATE_PATTERNS.containsKey(t)) {
|
||||||
|
return new JsonPrimitive(parseDate(s, DATE_PATTERNS.get(t)).toString());
|
||||||
|
}
|
||||||
|
switch (t) {
|
||||||
|
case "NUMBER":
|
||||||
|
case "AMOUNT_INTEGER": {
|
||||||
|
final String numeric = s.replaceAll("[^0-9.\\-]", "");
|
||||||
|
return numeric.isEmpty() ? null : new JsonPrimitive(new BigDecimal(numeric));
|
||||||
|
}
|
||||||
|
case "BOOLEAN":
|
||||||
|
return new JsonPrimitive("true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s));
|
||||||
|
case "UPPER":
|
||||||
|
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
|
||||||
|
case "LOWER":
|
||||||
|
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
|
||||||
|
case "TRIM":
|
||||||
|
return new JsonPrimitive(s.replaceAll("\\s+", " "));
|
||||||
|
case "DIGITS_ONLY":
|
||||||
|
return new JsonPrimitive(s.replaceAll("\\D", ""));
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a bureau date in the given pattern, falling back to ISO. */
|
||||||
|
public static LocalDate parseDate(final String s, final String pattern) {
|
||||||
|
final String v = s.trim();
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(v, DateTimeFormatter.ofPattern(pattern));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return LocalDate.parse(v.length() > 10 ? v.substring(0, 10) : v, ISO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ABCDE1234F -> AB****234F style masking for screens and logs. */
|
||||||
|
public static String mask(final String value) {
|
||||||
|
if (value == null || value.length() <= 4) {
|
||||||
|
return value == null ? null : "****";
|
||||||
|
}
|
||||||
|
final int keepStart = Math.min(2, value.length() / 4);
|
||||||
|
final int keepEnd = Math.min(4, value.length() / 3);
|
||||||
|
return value.substring(0, keepStart) + "*".repeat(value.length() - keepStart - keepEnd) + value.substring(value.length() - keepEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aadhaar shown as XXXX-XXXX-1234 whatever form the bureau sends it in. */
|
||||||
|
public static String maskAadhaar(final String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String digits = value.replaceAll("\\D", "");
|
||||||
|
return digits.length() < 4 ? null : "XXXX-XXXX-" + digits.substring(digits.length() - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
+463
@@ -0,0 +1,463 @@
|
|||||||
|
/**
|
||||||
|
* 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.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
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 bureau master (whitelist, environment, connection, encrypted credentials) and each bureau's API mapping:
|
||||||
|
* endpoint, headers, base body and the REQUEST / RESPONSE field mappings.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicConfigService {
|
||||||
|
|
||||||
|
private static final Pattern RESULT_KEY = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,99}$");
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final CicHttpGateway gateway;
|
||||||
|
|
||||||
|
// ── Bureaus ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Full row including the encrypted secret - internal use only, never returned by the API. */
|
||||||
|
public Map<String, Object> bureauRow(final Long id) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_bureau WHERE id = ?", id);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.cic.bureau.not.found", "Credit bureau " + id + " was not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listBureaus(final boolean whitelistedOnly) {
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : jdbcTemplate.queryForList(
|
||||||
|
"SELECT * FROM m_cic_bureau" + (whitelistedOnly ? " WHERE whitelisted = TRUE" : "") + " ORDER BY is_default DESC, id")) {
|
||||||
|
out.add(publicView(row));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getBureau(final Long id) {
|
||||||
|
return publicView(bureauRow(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bureau row as the screens see it: never the secret itself, only whether one is set. */
|
||||||
|
private Map<String, Object> publicView(final Map<String, Object> row) {
|
||||||
|
final Map<String, Object> v = new LinkedHashMap<>();
|
||||||
|
final Long id = ((Number) row.get("id")).longValue();
|
||||||
|
v.put("id", id);
|
||||||
|
v.put("code", row.get("code"));
|
||||||
|
v.put("name", row.get("name"));
|
||||||
|
v.put("description", row.get("description"));
|
||||||
|
v.put("whitelisted", row.get("whitelisted"));
|
||||||
|
v.put("isDefault", row.get("is_default"));
|
||||||
|
v.put("environment", row.get("environment"));
|
||||||
|
v.put("baseUrl", row.get("base_url"));
|
||||||
|
v.put("memberCode", row.get("member_code"));
|
||||||
|
v.put("authType", row.get("auth_type"));
|
||||||
|
v.put("username", row.get("username"));
|
||||||
|
v.put("apiKeyHeader", row.get("api_key_header"));
|
||||||
|
v.put("hasSecret", row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank());
|
||||||
|
v.put("tokenUrl", row.get("token_url"));
|
||||||
|
v.put("clientId", row.get("client_id"));
|
||||||
|
v.put("timeoutMs", row.get("timeout_ms"));
|
||||||
|
v.put("alertAutoFetch", row.get("alert_auto_fetch"));
|
||||||
|
v.put("alertFetchIntervalMin", row.get("alert_fetch_interval_min"));
|
||||||
|
v.put("alertLookbackDays", row.get("alert_lookback_days"));
|
||||||
|
v.put("lastFetchOn", row.get("last_fetch_on") == null ? null : row.get("last_fetch_on").toString());
|
||||||
|
v.put("lastFetchStatus", row.get("last_fetch_status"));
|
||||||
|
v.put("lastFetchMessage", row.get("last_fetch_message"));
|
||||||
|
v.put("lastTestStatus", row.get("last_test_status"));
|
||||||
|
v.put("lastTestMessage", row.get("last_test_message"));
|
||||||
|
v.put("lastTestedOn", row.get("last_tested_on") == null ? null : row.get("last_tested_on").toString());
|
||||||
|
v.put("updatedOn", row.get("updated_on") == null ? null : row.get("updated_on").toString());
|
||||||
|
v.put("activeEndpoints", jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, id));
|
||||||
|
v.put("alertsLast30Days", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND received_on >= ?",
|
||||||
|
Integer.class, id, Timestamp.from(Instant.now().minusSeconds(30L * 86400))));
|
||||||
|
v.put("newAlerts", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND status = 'NEW'",
|
||||||
|
Integer.class, id));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> updateBureau(final Long id, final JsonObject in, final AppUser user) {
|
||||||
|
final Map<String, Object> row = bureauRow(id);
|
||||||
|
final String environment = upper(text(in, "environment", (String) row.get("environment")));
|
||||||
|
if (!CicCatalog.ENVIRONMENTS.contains(environment)) {
|
||||||
|
throw fail("error.msg.cic.environment.invalid", "Environment must be one of " + CicCatalog.ENVIRONMENTS + ".");
|
||||||
|
}
|
||||||
|
final String authType = upper(text(in, "authType", (String) row.get("auth_type")));
|
||||||
|
if (!CicCatalog.AUTH_TYPES.contains(authType)) {
|
||||||
|
throw fail("error.msg.cic.auth.invalid", "Authentication must be one of " + CicCatalog.AUTH_TYPES + ".");
|
||||||
|
}
|
||||||
|
final String baseUrl = trimToNull(text(in, "baseUrl", (String) row.get("base_url")));
|
||||||
|
if (baseUrl != null && !StringUtils.startsWithAny(baseUrl, "https://", "http://")) {
|
||||||
|
throw fail("error.msg.cic.url.invalid", "Base URL must start with https:// (or http:// for a test server).");
|
||||||
|
}
|
||||||
|
if ("PRODUCTION".equals(environment) && baseUrl != null && !baseUrl.startsWith("https://")) {
|
||||||
|
throw fail("error.msg.cic.url.https", "A PRODUCTION bureau must be reached over https://.");
|
||||||
|
}
|
||||||
|
final String tokenUrl = trimToNull(text(in, "tokenUrl", (String) row.get("token_url")));
|
||||||
|
final int timeoutMs = intOf(in, "timeoutMs", ((Number) row.get("timeout_ms")).intValue());
|
||||||
|
if (timeoutMs < 1000 || timeoutMs > 300000) {
|
||||||
|
throw fail("error.msg.cic.timeout.invalid", "Timeout must be between 1,000 and 300,000 milliseconds.");
|
||||||
|
}
|
||||||
|
final boolean autoFetch = in.has("alertAutoFetch") && !in.get("alertAutoFetch").isJsonNull() ? in.get("alertAutoFetch").getAsBoolean()
|
||||||
|
: Boolean.TRUE.equals(row.get("alert_auto_fetch"));
|
||||||
|
final int interval = intOf(in, "alertFetchIntervalMin", ((Number) row.get("alert_fetch_interval_min")).intValue());
|
||||||
|
if (interval < 15 || interval > 1440) {
|
||||||
|
throw fail("error.msg.cic.interval.invalid", "Automatic fetch interval must be between 15 minutes and 24 hours (1440).");
|
||||||
|
}
|
||||||
|
final int lookback = intOf(in, "alertLookbackDays", ((Number) row.get("alert_lookback_days")).intValue());
|
||||||
|
if (lookback < 1 || lookback > 90) {
|
||||||
|
throw fail("error.msg.cic.lookback.invalid", "Look-back window must be between 1 and 90 days.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String secretEnc = (String) row.get("secret_enc");
|
||||||
|
if (in.has("clearSecret") && in.get("clearSecret").getAsBoolean()) {
|
||||||
|
secretEnc = null;
|
||||||
|
} else if (in.has("secret") && !in.get("secret").isJsonNull() && !in.get("secret").getAsString().isBlank()) {
|
||||||
|
secretEnc = gateway.encrypt(in.get("secret").getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean makeDefault = in.has("isDefault") && !in.get("isDefault").isJsonNull() && in.get("isDefault").getAsBoolean();
|
||||||
|
if (makeDefault) {
|
||||||
|
jdbcTemplate.update("UPDATE m_cic_bureau SET is_default = FALSE WHERE id <> ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_cic_bureau SET name = ?, description = ?, environment = ?, base_url = ?, member_code = ?, auth_type = ?,
|
||||||
|
username = ?, api_key_header = ?, secret_enc = ?, token_url = ?, client_id = ?, timeout_ms = ?,
|
||||||
|
alert_auto_fetch = ?, alert_fetch_interval_min = ?, alert_lookback_days = ?, is_default = ?, updated_by = ?,
|
||||||
|
updated_on = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", StringUtils.defaultIfBlank(text(in, "name", (String) row.get("name")), (String) row.get("name")),
|
||||||
|
trimToNull(text(in, "description", (String) row.get("description"))), environment, baseUrl,
|
||||||
|
trimToNull(text(in, "memberCode", (String) row.get("member_code"))), authType,
|
||||||
|
trimToNull(text(in, "username", (String) row.get("username"))),
|
||||||
|
StringUtils.defaultIfBlank(text(in, "apiKeyHeader", (String) row.get("api_key_header")), "X-API-KEY"), secretEnc, tokenUrl,
|
||||||
|
trimToNull(text(in, "clientId", (String) row.get("client_id"))), timeoutMs, autoFetch, interval, lookback,
|
||||||
|
makeDefault || Boolean.TRUE.equals(row.get("is_default")), user.getId(), Timestamp.from(Instant.now()), id);
|
||||||
|
gateway.evictToken(id);
|
||||||
|
|
||||||
|
// A whitelisted bureau must stay usable after the edit; otherwise every alert fetch would fail silently.
|
||||||
|
if (Boolean.TRUE.equals(row.get("whitelisted"))) {
|
||||||
|
assertReadyForLive(bureauRow(id));
|
||||||
|
}
|
||||||
|
return getBureau(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> setWhitelisted(final Long id, final boolean whitelisted, final AppUser user) {
|
||||||
|
final Map<String, Object> row = bureauRow(id);
|
||||||
|
if (whitelisted) {
|
||||||
|
assertReadyForLive(row);
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("UPDATE m_cic_bureau SET whitelisted = ?, updated_by = ?, updated_on = ? WHERE id = ?", whitelisted,
|
||||||
|
user.getId(), Timestamp.from(Instant.now()), id);
|
||||||
|
return getBureau(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a bureau needs before alerts may be fetched from it. */
|
||||||
|
public void assertReadyForLive(final Map<String, Object> row) {
|
||||||
|
final String code = (String) row.get("code");
|
||||||
|
final Integer endpoints = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, row.get("id"));
|
||||||
|
if (endpoints == null || endpoints == 0) {
|
||||||
|
throw fail("error.msg.cic.no.endpoint", code + " has no active API endpoint. Configure it under API Mapping first.");
|
||||||
|
}
|
||||||
|
if ("SIMULATOR".equals(row.get("environment"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank((String) row.get("base_url"))) {
|
||||||
|
throw fail("error.msg.cic.no.url", code + " has no base URL for " + row.get("environment") + ".");
|
||||||
|
}
|
||||||
|
final String auth = (String) row.get("auth_type");
|
||||||
|
final boolean hasSecret = row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank();
|
||||||
|
if (!"NONE".equals(auth) && !hasSecret) {
|
||||||
|
throw fail("error.msg.cic.no.secret", code + " uses " + auth + " but no password / key / secret is stored.");
|
||||||
|
}
|
||||||
|
if ("BASIC".equals(auth) && StringUtils.isBlank((String) row.get("username"))) {
|
||||||
|
throw fail("error.msg.cic.no.username", code + " uses BASIC authentication but has no username.");
|
||||||
|
}
|
||||||
|
if ("OAUTH2_CLIENT_CREDENTIALS".equals(auth)
|
||||||
|
&& (StringUtils.isBlank((String) row.get("token_url")) || StringUtils.isBlank((String) row.get("client_id")))) {
|
||||||
|
throw fail("error.msg.cic.no.oauth", code + " uses OAuth2 but the token URL or client id is missing.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability and credentials check; the result is kept on the bureau for the configuration screen. */
|
||||||
|
public Map<String, Object> testConnection(final Long id) {
|
||||||
|
final Map<String, Object> row = bureauRow(id);
|
||||||
|
String status;
|
||||||
|
String message;
|
||||||
|
if ("SIMULATOR".equals(row.get("environment"))) {
|
||||||
|
status = "OK";
|
||||||
|
message = "Simulator mode - no network call is made; alerts are answered by the built-in simulator.";
|
||||||
|
} else if (StringUtils.isBlank((String) row.get("base_url"))) {
|
||||||
|
status = "FAILED";
|
||||||
|
message = "No base URL configured.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type"))) {
|
||||||
|
gateway.evictToken(id);
|
||||||
|
gateway.oauthToken(row);
|
||||||
|
}
|
||||||
|
final CicHttpGateway.HttpResult r = gateway.send(row, "GET", (String) row.get("base_url"), Map.of(), null, null);
|
||||||
|
if (r.status() == 0) {
|
||||||
|
status = "FAILED";
|
||||||
|
message = "Not reachable: " + r.error();
|
||||||
|
} else if (r.status() == 401 || r.status() == 403) {
|
||||||
|
status = "FAILED";
|
||||||
|
message = "Reachable, but the bureau refused the credentials (HTTP " + r.status() + ").";
|
||||||
|
} else {
|
||||||
|
status = "OK";
|
||||||
|
message = "Reachable (HTTP " + r.status() + " in " + r.durationMs() + " ms)"
|
||||||
|
+ ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type")) ? ", OAuth2 token obtained." : ".");
|
||||||
|
}
|
||||||
|
} catch (final IOException | RuntimeException e) {
|
||||||
|
status = "FAILED";
|
||||||
|
message = e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("UPDATE m_cic_bureau SET last_test_status = ?, last_test_message = ?, last_tested_on = ? WHERE id = ?", status,
|
||||||
|
StringUtils.abbreviate(message, 1000), Timestamp.from(Instant.now()), id);
|
||||||
|
final Map<String, Object> res = new LinkedHashMap<>();
|
||||||
|
res.put("status", status);
|
||||||
|
res.put("message", message);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Endpoints and mappings ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Map<String, Object> endpointRow(final Long bureauId, final String type) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT * FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND endpoint_type = ?", bureauId, type);
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> mappingRows(final Long endpointId, final String direction) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT * FROM m_cic_field_mapping WHERE endpoint_id = ? AND direction = ? ORDER BY sort_order, id", endpointId, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getEndpoint(final Long bureauId, final String type) {
|
||||||
|
bureauRow(bureauId);
|
||||||
|
final String t = upper(type);
|
||||||
|
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
|
||||||
|
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
|
||||||
|
}
|
||||||
|
final Map<String, Object> e = endpointRow(bureauId, t);
|
||||||
|
final Map<String, Object> v = new LinkedHashMap<>();
|
||||||
|
v.put("bureauId", bureauId);
|
||||||
|
v.put("endpointType", t);
|
||||||
|
v.put("exists", e != null);
|
||||||
|
v.put("id", e == null ? null : e.get("id"));
|
||||||
|
v.put("httpMethod", e == null ? "POST" : e.get("http_method"));
|
||||||
|
v.put("path", e == null ? null : e.get("path"));
|
||||||
|
v.put("contentType", e == null ? "application/json" : e.get("content_type"));
|
||||||
|
v.put("headersJson", e == null ? "{}" : e.get("headers_json"));
|
||||||
|
v.put("baseBody", e == null ? "{}" : e.get("base_body"));
|
||||||
|
v.put("isActive", e == null ? Boolean.FALSE : e.get("is_active"));
|
||||||
|
v.put("requestMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "REQUEST")));
|
||||||
|
v.put("responseMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "RESPONSE")));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Map<String, Object>> mappingView(final List<Map<String, Object>> rows) {
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> m : rows) {
|
||||||
|
final Map<String, Object> v = new LinkedHashMap<>();
|
||||||
|
v.put("id", m.get("id"));
|
||||||
|
v.put("sourceField", m.get("source_field"));
|
||||||
|
v.put("targetField", m.get("target_field"));
|
||||||
|
v.put("transform", m.get("transform"));
|
||||||
|
v.put("defaultValue", m.get("default_value"));
|
||||||
|
v.put("mandatory", m.get("is_mandatory"));
|
||||||
|
v.put("sortOrder", m.get("sort_order"));
|
||||||
|
out.add(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves an endpoint and replaces its whole mapping in one transaction, after validating every row. */
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> saveEndpoint(final Long bureauId, final String type, final JsonObject in, final AppUser user) {
|
||||||
|
final Map<String, Object> bureau = bureauRow(bureauId);
|
||||||
|
final String t = upper(type);
|
||||||
|
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
|
||||||
|
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
|
||||||
|
}
|
||||||
|
final String method = upper(StringUtils.defaultIfBlank(text(in, "httpMethod", "POST"), "POST"));
|
||||||
|
if (!CicCatalog.HTTP_METHODS.contains(method)) {
|
||||||
|
throw fail("error.msg.cic.method.invalid", "HTTP method must be one of " + CicCatalog.HTTP_METHODS + ".");
|
||||||
|
}
|
||||||
|
final String headersJson = StringUtils.defaultIfBlank(text(in, "headersJson", "{}"), "{}");
|
||||||
|
final String baseBody = StringUtils.defaultIfBlank(text(in, "baseBody", "{}"), "{}");
|
||||||
|
requireJsonObject(headersJson, "Headers");
|
||||||
|
requireJsonObject(baseBody, "Base request body");
|
||||||
|
for (final Map.Entry<String, JsonElement> h : JsonParser.parseString(headersJson).getAsJsonObject().entrySet()) {
|
||||||
|
if (!h.getValue().isJsonPrimitive()) {
|
||||||
|
throw fail("error.msg.cic.header.invalid", "Header '" + h.getKey() + "' must be a plain text value.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final boolean active = !in.has("isActive") || in.get("isActive").isJsonNull() || in.get("isActive").getAsBoolean();
|
||||||
|
|
||||||
|
final List<Object[]> rows = new ArrayList<>();
|
||||||
|
validateMappings(in.has("requestMappings") ? in.getAsJsonArray("requestMappings") : new JsonArray(), "REQUEST", rows);
|
||||||
|
validateMappings(in.has("responseMappings") ? in.getAsJsonArray("responseMappings") : new JsonArray(), "RESPONSE", rows);
|
||||||
|
if (active && rows.stream().noneMatch(r -> "REQUEST".equals(r[0]))) {
|
||||||
|
throw fail("error.msg.cic.no.request.mapping", "An active endpoint needs at least one REQUEST mapping.");
|
||||||
|
}
|
||||||
|
if (active && rows.stream().noneMatch(r -> "RESPONSE".equals(r[0]) && "alerts".equals(r[2]))) {
|
||||||
|
throw fail("error.msg.cic.no.alert.list",
|
||||||
|
"Map the response path of the alert list to the result field 'alerts' (e.g. data.alerts), or the alerts cannot be read.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> existing = endpointRow(bureauId, t);
|
||||||
|
final Timestamp now = Timestamp.from(Instant.now());
|
||||||
|
if (existing == null) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body,
|
||||||
|
is_active, updated_by, updated_on)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", bureauId, t, method, trimToNull(text(in, "path", null)),
|
||||||
|
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
|
||||||
|
user.getId(), now);
|
||||||
|
existing = endpointRow(bureauId, t);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_cic_bureau_endpoint SET http_method = ?, path = ?, content_type = ?, headers_json = ?, base_body = ?, is_active = ?,
|
||||||
|
updated_by = ?, updated_on = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", method, trimToNull(text(in, "path", null)),
|
||||||
|
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
|
||||||
|
user.getId(), now, existing.get("id"));
|
||||||
|
}
|
||||||
|
final Long endpointId = ((Number) existing.get("id")).longValue();
|
||||||
|
jdbcTemplate.update("DELETE FROM m_cic_field_mapping WHERE endpoint_id = ?", endpointId);
|
||||||
|
for (final Object[] r : rows) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory,
|
||||||
|
sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", endpointId, r[0], r[1], r[2], r[3], r[4], r[5], r[6]);
|
||||||
|
}
|
||||||
|
// Switching off the last active endpoint of a whitelisted bureau would leave it whitelisted but unusable.
|
||||||
|
if (Boolean.TRUE.equals(bureau.get("whitelisted"))) {
|
||||||
|
assertReadyForLive(bureauRow(bureauId));
|
||||||
|
}
|
||||||
|
return getEndpoint(bureauId, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMappings(final JsonArray array, final String direction, final List<Object[]> out) {
|
||||||
|
int order = 0;
|
||||||
|
for (final JsonElement el : array) {
|
||||||
|
final JsonObject m = el.getAsJsonObject();
|
||||||
|
order += 10;
|
||||||
|
final String source = trimToNull(text(m, "sourceField", null));
|
||||||
|
final String target = trimToNull(text(m, "targetField", null));
|
||||||
|
final String transform = StringUtils.defaultIfBlank(upper(text(m, "transform", "NONE")), "NONE");
|
||||||
|
final String defaultValue = trimToNull(text(m, "defaultValue", null));
|
||||||
|
final boolean mandatory = m.has("mandatory") && !m.get("mandatory").isJsonNull() && m.get("mandatory").getAsBoolean();
|
||||||
|
final int sortOrder = m.has("sortOrder") && !m.get("sortOrder").isJsonNull() ? m.get("sortOrder").getAsInt() : order;
|
||||||
|
final String label = direction + " row " + (order / 10);
|
||||||
|
if (!CicCatalog.TRANSFORMS.containsKey(transform)) {
|
||||||
|
throw fail("error.msg.cic.transform.invalid", label + ": unknown transform '" + transform + "'.");
|
||||||
|
}
|
||||||
|
if (target == null) {
|
||||||
|
throw fail("error.msg.cic.mapping.target.required", label + ": the target field is required.");
|
||||||
|
}
|
||||||
|
if ("REQUEST".equals(direction)) {
|
||||||
|
if (source == null && defaultValue == null) {
|
||||||
|
throw fail("error.msg.cic.mapping.source.required", label + " (" + target + "): choose a CBS field or give a fixed value.");
|
||||||
|
}
|
||||||
|
if (source != null && !CicCatalog.CBS_FIELDS.containsKey(source)) {
|
||||||
|
throw fail("error.msg.cic.mapping.source.invalid", label + ": unknown CBS field '" + source + "'.");
|
||||||
|
}
|
||||||
|
if (!CicJsonPath.isValid(target)) {
|
||||||
|
throw fail("error.msg.cic.mapping.path.invalid", label + ": '" + target + "' is not a valid JSON path (e.g. consumer.ids[0].number).");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (source == null || !CicJsonPath.isValid(source)) {
|
||||||
|
throw fail("error.msg.cic.mapping.path.invalid",
|
||||||
|
label + ": the bureau response path is missing or invalid (e.g. report.score.value).");
|
||||||
|
}
|
||||||
|
if (!RESULT_KEY.matcher(target).matches()) {
|
||||||
|
throw fail("error.msg.cic.mapping.result.invalid", label + ": result field '" + target + "' must be a simple name.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.add(new Object[] { direction, source, target, transform, defaultValue, mandatory, sortOrder });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void requireJsonObject(final String text, final String label) {
|
||||||
|
try {
|
||||||
|
if (!JsonParser.parseString(text).isJsonObject()) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
throw fail("error.msg.cic.json.invalid", label + " must be a valid JSON object.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String text(final JsonObject in, final String key, final String fallback) {
|
||||||
|
if (in == null || !in.has(key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return in.get(key).isJsonNull() ? null : in.get(key).getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intOf(final JsonObject in, final String key, final int fallback) {
|
||||||
|
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsInt() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String upper(final String s) {
|
||||||
|
return s == null ? null : s.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(final String s) {
|
||||||
|
return StringUtils.trimToNull(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GeneralPlatformDomainRuleException fail(final String code, final String message) {
|
||||||
|
return new GeneralPlatformDomainRuleException(code, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 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.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import okhttp3.FormBody;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.RequestBody;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import okhttp3.ResponseBody;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound HTTP to a credit bureau: URL joining, authentication (Basic, API key, bearer, OAuth2 client credentials
|
||||||
|
* with an in-memory token cache), per-bureau timeouts. The bureau's secret is stored encrypted and is only decrypted
|
||||||
|
* here, for the call itself.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicHttpGateway {
|
||||||
|
|
||||||
|
private static final int MAX_BODY_CHARS = 2_000_000;
|
||||||
|
|
||||||
|
private final DatabasePasswordEncryptor encryptor;
|
||||||
|
private final Map<Long, CachedToken> tokens = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private record CachedToken(String token, Instant expiresAt) {}
|
||||||
|
|
||||||
|
/** What came back from the bureau. A transport failure is a status of 0 with the error in {@code body}. */
|
||||||
|
public record HttpResult(int status, String body, long durationMs, String error) {
|
||||||
|
|
||||||
|
public boolean ok() {
|
||||||
|
return status >= 200 && status < 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String encrypt(final String plain) {
|
||||||
|
return plain == null ? null : encryptor.encrypt(plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String secretOf(final Map<String, Object> bureau) {
|
||||||
|
final Object enc = bureau.get("secret_enc");
|
||||||
|
if (enc == null || enc.toString().isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return encryptor.decrypt(enc.toString());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.cic.secret.unreadable",
|
||||||
|
"The stored credential of " + bureau.get("code") + " cannot be decrypted (was the master password changed?)."
|
||||||
|
+ " Re-enter it on the bureau configuration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String joinUrl(final String baseUrl, final String path) {
|
||||||
|
if (StringUtils.startsWithAny(path, "http://", "https://")) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
final String base = StringUtils.defaultString(baseUrl).replaceAll("/+$", "");
|
||||||
|
if (StringUtils.isBlank(path)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return base + (path.startsWith("/") ? path : "/" + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OkHttpClient client(final Map<String, Object> bureau) {
|
||||||
|
final int timeout = bureau.get("timeout_ms") instanceof Number n && n.intValue() > 0 ? n.intValue() : 30000;
|
||||||
|
return new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS).readTimeout(timeout, TimeUnit.MILLISECONDS)
|
||||||
|
.writeTimeout(timeout, TimeUnit.MILLISECONDS).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends one request to the bureau with its configured authentication. */
|
||||||
|
public HttpResult send(final Map<String, Object> bureau, final String method, final String url, final Map<String, String> headers,
|
||||||
|
final String contentType, final String body) {
|
||||||
|
final long started = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
final Request.Builder rb = new Request.Builder().url(url);
|
||||||
|
headers.forEach((k, v) -> {
|
||||||
|
if (StringUtils.isNotBlank(k) && v != null) {
|
||||||
|
rb.header(k, v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
applyAuth(bureau, rb);
|
||||||
|
final String m = StringUtils.defaultIfBlank(method, "POST").toUpperCase();
|
||||||
|
if ("GET".equals(m)) {
|
||||||
|
rb.get();
|
||||||
|
} else {
|
||||||
|
final MediaType mediaType = MediaType.parse(StringUtils.defaultIfBlank(contentType, "application/json"));
|
||||||
|
rb.method(m, RequestBody.create(StringUtils.defaultString(body), mediaType));
|
||||||
|
}
|
||||||
|
try (Response response = client(bureau).newCall(rb.build()).execute()) {
|
||||||
|
return new HttpResult(response.code(), read(response.body()), System.currentTimeMillis() - started, null);
|
||||||
|
}
|
||||||
|
} catch (final IOException | IllegalArgumentException e) {
|
||||||
|
log.warn("CIC {}: call to {} failed: {}", bureau.get("code"), url, e.getMessage());
|
||||||
|
return new HttpResult(0, null, System.currentTimeMillis() - started, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String read(final ResponseBody body) throws IOException {
|
||||||
|
if (body == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String text = new String(body.bytes(), StandardCharsets.UTF_8);
|
||||||
|
return text.length() > MAX_BODY_CHARS ? text.substring(0, MAX_BODY_CHARS) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyAuth(final Map<String, Object> bureau, final Request.Builder rb) throws IOException {
|
||||||
|
final String auth = StringUtils.defaultIfBlank((String) bureau.get("auth_type"), "NONE");
|
||||||
|
final String username = (String) bureau.get("username");
|
||||||
|
switch (auth) {
|
||||||
|
case "BASIC" -> {
|
||||||
|
final String raw = StringUtils.defaultString(username) + ":" + StringUtils.defaultString(secretOf(bureau));
|
||||||
|
rb.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
case "API_KEY" -> rb.header(StringUtils.defaultIfBlank((String) bureau.get("api_key_header"), "X-API-KEY"),
|
||||||
|
StringUtils.defaultString(secretOf(bureau)));
|
||||||
|
case "BEARER_TOKEN" -> rb.header("Authorization", "Bearer " + StringUtils.defaultString(secretOf(bureau)));
|
||||||
|
case "OAUTH2_CLIENT_CREDENTIALS" -> rb.header("Authorization", "Bearer " + oauthToken(bureau));
|
||||||
|
default -> {
|
||||||
|
// NONE: the bureau authenticates by IP whitelisting / mutual TLS at the network layer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth2 client-credentials token, cached until a minute before it expires. */
|
||||||
|
public String oauthToken(final Map<String, Object> bureau) throws IOException {
|
||||||
|
final Long id = ((Number) bureau.get("id")).longValue();
|
||||||
|
final CachedToken cached = tokens.get(id);
|
||||||
|
if (cached != null && Instant.now().isBefore(cached.expiresAt())) {
|
||||||
|
return cached.token();
|
||||||
|
}
|
||||||
|
final String tokenUrl = (String) bureau.get("token_url");
|
||||||
|
if (StringUtils.isBlank(tokenUrl)) {
|
||||||
|
throw new IOException("OAuth2 token URL is not configured");
|
||||||
|
}
|
||||||
|
final FormBody form = new FormBody.Builder().add("grant_type", "client_credentials")
|
||||||
|
.add("client_id", StringUtils.defaultString((String) bureau.get("client_id")))
|
||||||
|
.add("client_secret", StringUtils.defaultString(secretOf(bureau))).build();
|
||||||
|
try (Response response = client(bureau).newCall(new Request.Builder().url(tokenUrl).post(form).build()).execute()) {
|
||||||
|
final String text = read(response.body());
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
throw new IOException("Token endpoint returned HTTP " + response.code() + ": " + StringUtils.abbreviate(text, 300));
|
||||||
|
}
|
||||||
|
final JsonElement json = JsonParser.parseString(StringUtils.defaultIfBlank(text, "{}"));
|
||||||
|
final JsonObject obj = json.isJsonObject() ? json.getAsJsonObject() : new JsonObject();
|
||||||
|
if (!obj.has("access_token")) {
|
||||||
|
throw new IOException("Token endpoint response has no access_token");
|
||||||
|
}
|
||||||
|
final long expiresIn = obj.has("expires_in") ? obj.get("expires_in").getAsLong() : 300L;
|
||||||
|
final String token = obj.get("access_token").getAsString();
|
||||||
|
tokens.put(id, new CachedToken(token, Instant.now().plusSeconds(Math.max(30, expiresIn - 60))));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget a cached token (after the bureau's credentials change). */
|
||||||
|
public void evictToken(final Long bureauId) {
|
||||||
|
tokens.remove(bureauId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonNull;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal dotted JSON path used by the bureau API mapping: {@code consumer.name.firstName},
|
||||||
|
* {@code consumer.ids[0].number}, {@code report.score.value}. Setting creates the missing objects and arrays on the
|
||||||
|
* way; reading returns null for anything absent.
|
||||||
|
*/
|
||||||
|
public final class CicJsonPath {
|
||||||
|
|
||||||
|
private static final Pattern SEGMENT = Pattern.compile("^([^\\[\\]]*)((?:\\[\\d+\\])*)$");
|
||||||
|
private static final Pattern INDEX = Pattern.compile("\\[(\\d+)\\]");
|
||||||
|
|
||||||
|
private CicJsonPath() {}
|
||||||
|
|
||||||
|
/** One step of a path: an object key, optionally followed by array indexes. */
|
||||||
|
private record Step(String key, List<Integer> indexes) {}
|
||||||
|
|
||||||
|
private static List<Step> parse(final String path) {
|
||||||
|
if (path == null || path.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Empty JSON path");
|
||||||
|
}
|
||||||
|
final List<Step> steps = new ArrayList<>();
|
||||||
|
for (final String raw : path.trim().split("\\.")) {
|
||||||
|
final Matcher m = SEGMENT.matcher(raw);
|
||||||
|
if (!m.matches()) {
|
||||||
|
throw new IllegalArgumentException("Invalid JSON path segment '" + raw + "' in '" + path + "'");
|
||||||
|
}
|
||||||
|
final List<Integer> indexes = new ArrayList<>();
|
||||||
|
final Matcher im = INDEX.matcher(m.group(2));
|
||||||
|
while (im.find()) {
|
||||||
|
indexes.add(Integer.parseInt(im.group(1)));
|
||||||
|
}
|
||||||
|
steps.add(new Step(m.group(1), indexes));
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the path is syntactically valid. */
|
||||||
|
public static boolean isValid(final String path) {
|
||||||
|
try {
|
||||||
|
parse(path);
|
||||||
|
return true;
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonElement get(final JsonElement root, final String path) {
|
||||||
|
JsonElement current = root;
|
||||||
|
for (final Step step : parse(path)) {
|
||||||
|
if (!step.key().isEmpty()) {
|
||||||
|
if (current == null || !current.isJsonObject()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
current = current.getAsJsonObject().get(step.key());
|
||||||
|
}
|
||||||
|
for (final Integer index : step.indexes()) {
|
||||||
|
if (current == null || !current.isJsonArray() || current.getAsJsonArray().size() <= index) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
current = current.getAsJsonArray().get(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current == null || current.isJsonNull() ? null : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void set(final JsonObject root, final String path, final JsonElement value) {
|
||||||
|
final List<Step> steps = parse(path);
|
||||||
|
JsonElement current = root;
|
||||||
|
for (int s = 0; s < steps.size(); s++) {
|
||||||
|
final Step step = steps.get(s);
|
||||||
|
final boolean lastStep = s == steps.size() - 1;
|
||||||
|
if (!step.key().isEmpty()) {
|
||||||
|
final JsonObject obj = current.getAsJsonObject();
|
||||||
|
if (lastStep && step.indexes().isEmpty()) {
|
||||||
|
obj.add(step.key(), value == null ? JsonNull.INSTANCE : value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonElement next = obj.get(step.key());
|
||||||
|
if (next == null || next.isJsonNull()) {
|
||||||
|
next = step.indexes().isEmpty() ? new JsonObject() : new JsonArray();
|
||||||
|
obj.add(step.key(), next);
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < step.indexes().size(); i++) {
|
||||||
|
final int index = step.indexes().get(i);
|
||||||
|
final boolean lastIndex = lastStep && i == step.indexes().size() - 1;
|
||||||
|
final JsonArray array = current.getAsJsonArray();
|
||||||
|
while (array.size() <= index) {
|
||||||
|
array.add(JsonNull.INSTANCE);
|
||||||
|
}
|
||||||
|
if (lastIndex) {
|
||||||
|
array.set(index, value == null ? JsonNull.INSTANCE : value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonElement next = array.get(index);
|
||||||
|
if (next == null || next.isJsonNull()) {
|
||||||
|
next = i < step.indexes().size() - 1 ? new JsonArray() : new JsonObject();
|
||||||
|
array.set(index, next);
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* 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.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Random;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in CIC for the SIMULATOR environment. Plays the bureau's enquiry-alert service: for every day of the asked
|
||||||
|
* window it reports a few of this bank's own customers as enquired by other lenders, in the shape of the seeded generic
|
||||||
|
* mapping. Deterministic per bureau and day, so fetching the same window again yields the same alerts (and they are
|
||||||
|
* de-duplicated). Now and then it also sends an alert for an unknown customer and one raised by this bank itself, so
|
||||||
|
* the unmatched and own-bank paths can be seen too. Never used once the bureau is set to UAT or PRODUCTION.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicSimulator {
|
||||||
|
|
||||||
|
private static final String[][] LENDERS = { { "HDFC BANK LTD", "BANK" }, { "STATE BANK OF INDIA", "BANK" }, { "ICICI BANK LTD", "BANK" },
|
||||||
|
{ "BAJAJ FINANCE LTD", "NBFC" }, { "MUTHOOT FINANCE LTD", "NBFC" }, { "UJJIVAN SMALL FINANCE BANK", "SMALL FINANCE BANK" },
|
||||||
|
{ "BANDHAN BANK LTD", "BANK" }, { "SATIN CREDITCARE NETWORK", "MFI" }, { "MADHYA PRADESH GRAMIN BANK", "REGIONAL RURAL BANK" },
|
||||||
|
{ "SARASWAT CO-OPERATIVE BANK", "CO-OPERATIVE BANK" }, { "TATA CAPITAL LTD", "NBFC" }, { "PUNJAB NATIONAL BANK", "BANK" } };
|
||||||
|
private static final Object[][] LOAN_TYPES = { { "PERSONAL LOAN", 50_000, 1_000_000 }, { "HOUSING LOAN", 800_000, 6_000_000 },
|
||||||
|
{ "AUTO LOAN", 300_000, 1_500_000 }, { "TWO-WHEELER LOAN", 50_000, 200_000 }, { "GOLD LOAN", 20_000, 500_000 },
|
||||||
|
{ "BUSINESS LOAN", 200_000, 5_000_000 }, { "CREDIT CARD", 25_000, 300_000 }, { "KISAN CREDIT CARD", 50_000, 300_000 },
|
||||||
|
{ "EDUCATION LOAN", 200_000, 2_000_000 }, { "CONSUMER DURABLE LOAN", 10_000, 150_000 },
|
||||||
|
{ "LOAN AGAINST PROPERTY", 500_000, 5_000_000 }, { "MICROFINANCE LOAN", 20_000, 100_000 } };
|
||||||
|
private static final String[] CITIES = { "BHOPAL", "INDORE", "VIDISHA", "SAGAR", "JABALPUR", "GWALIOR", "BASODA", "RAISEN" };
|
||||||
|
private static final DateTimeFormatter DDMMYYYY = DateTimeFormatter.ofPattern("ddMMyyyy");
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public JsonObject respond(final Map<String, Object> bureau, final LocalDate from, final LocalDate to, final String reference) {
|
||||||
|
final String code = String.valueOf(bureau.get("code"));
|
||||||
|
final String ownMemberCode = bureau.get("member_code") == null ? "OWNBANK" : bureau.get("member_code").toString();
|
||||||
|
final List<Map<String, Object>> clients = jdbcTemplate.queryForList("""
|
||||||
|
SELECT c.id, c.display_name, c.account_no, c.date_of_birth,
|
||||||
|
(SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL
|
||||||
|
ORDER BY k.id DESC LIMIT 1) AS pan,
|
||||||
|
(SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id
|
||||||
|
AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar
|
||||||
|
FROM m_client c
|
||||||
|
WHERE c.status_enum = 300
|
||||||
|
ORDER BY c.id
|
||||||
|
LIMIT 300
|
||||||
|
""");
|
||||||
|
|
||||||
|
final JsonArray alerts = new JsonArray();
|
||||||
|
final long days = Math.min(90, ChronoUnit.DAYS.between(from, to));
|
||||||
|
for (int d = 0; d <= days; d++) {
|
||||||
|
final LocalDate day = from.plusDays(d);
|
||||||
|
final Random r = new Random(Math.abs((long) (code + "|" + day).hashCode()));
|
||||||
|
final int count = clients.isEmpty() ? 0 : r.nextInt(4);
|
||||||
|
int seq = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, null));
|
||||||
|
}
|
||||||
|
if (r.nextInt(8) == 0) {
|
||||||
|
// A consumer the bureau has on file for this bank but whom the bank no longer has (or never had) under that id.
|
||||||
|
alerts.add(alert(code, day, ++seq, Map.of("display_name", "UNKNOWN CUSTOMER", "pan", "ZZZPZ" + (1000 + r.nextInt(8999)) + "Z"),
|
||||||
|
r, null));
|
||||||
|
}
|
||||||
|
if (!clients.isEmpty() && r.nextInt(10) == 0) {
|
||||||
|
// An enquiry this bank made itself - the bureau reports it, the CBS must skip it.
|
||||||
|
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, ownMemberCode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject header = new JsonObject();
|
||||||
|
header.addProperty("status", "SUCCESS");
|
||||||
|
header.addProperty("referenceNumber", reference);
|
||||||
|
header.addProperty("bureau", code);
|
||||||
|
header.addProperty("fromDate", from.format(DDMMYYYY));
|
||||||
|
header.addProperty("toDate", to.format(DDMMYYYY));
|
||||||
|
final JsonObject data = new JsonObject();
|
||||||
|
data.addProperty("totalAlerts", alerts.size());
|
||||||
|
data.add("alerts", alerts);
|
||||||
|
final JsonObject root = new JsonObject();
|
||||||
|
root.add("responseHeader", header);
|
||||||
|
root.add("data", data);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject alert(final String code, final LocalDate day, final int seq, final Map<String, Object> client, final Random r,
|
||||||
|
final String forcedMemberCode) {
|
||||||
|
final JsonObject consumer = new JsonObject();
|
||||||
|
consumer.addProperty("name", String.valueOf(client.get("display_name")).toUpperCase());
|
||||||
|
final Object pan = client.get("pan");
|
||||||
|
final Object aadhaar = client.get("aadhaar");
|
||||||
|
if (pan != null && !pan.toString().isBlank()) {
|
||||||
|
consumer.addProperty("pan", pan.toString().trim().toUpperCase());
|
||||||
|
} else if (aadhaar != null && !aadhaar.toString().isBlank()) {
|
||||||
|
consumer.addProperty("aadhaar", "XXXXXXXX" + lastFour(aadhaar.toString()));
|
||||||
|
} else if (client.get("account_no") != null) {
|
||||||
|
consumer.addProperty("memberReference", client.get("account_no").toString());
|
||||||
|
}
|
||||||
|
if (client.get("date_of_birth") != null) {
|
||||||
|
consumer.addProperty("dateOfBirth", LocalDate.parse(client.get("date_of_birth").toString().substring(0, 10)).format(DDMMYYYY));
|
||||||
|
}
|
||||||
|
|
||||||
|
final int lenderIdx = r.nextInt(LENDERS.length);
|
||||||
|
final Object[] type = LOAN_TYPES[r.nextInt(LOAN_TYPES.length)];
|
||||||
|
final int min = (Integer) type[1];
|
||||||
|
final int max = (Integer) type[2];
|
||||||
|
final long amount = Math.round((min + r.nextDouble() * (max - min)) / 5000.0) * 5000L;
|
||||||
|
|
||||||
|
final JsonObject enquiry = new JsonObject();
|
||||||
|
enquiry.addProperty("memberCode", forcedMemberCode != null ? forcedMemberCode : "SIM" + String.format("%04d", 101 + lenderIdx));
|
||||||
|
enquiry.addProperty("memberName", forcedMemberCode != null ? "THIS BANK" : LENDERS[lenderIdx][0]);
|
||||||
|
enquiry.addProperty("memberType", forcedMemberCode != null ? "BANK" : LENDERS[lenderIdx][1]);
|
||||||
|
enquiry.addProperty("date", day.format(DDMMYYYY));
|
||||||
|
enquiry.addProperty("productType", (String) type[0]);
|
||||||
|
enquiry.addProperty("amount", amount);
|
||||||
|
enquiry.addProperty("purpose", r.nextInt(5) == 0 ? "LOAN RENEWAL / ENHANCEMENT" : "NEW LOAN APPLICATION");
|
||||||
|
enquiry.addProperty("location", CITIES[r.nextInt(CITIES.length)]);
|
||||||
|
|
||||||
|
final JsonObject a = new JsonObject();
|
||||||
|
a.addProperty("alertId", code + "-" + day.format(DDMMYYYY) + "-" + seq);
|
||||||
|
a.add("consumer", consumer);
|
||||||
|
a.add("enquiry", enquiry);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String lastFour(final String value) {
|
||||||
|
final String digits = value.replaceAll("\\D", "");
|
||||||
|
return digits.length() >= 4 ? digits.substring(digits.length() - 4) : "0000";
|
||||||
|
}
|
||||||
|
}
|
||||||
+208
-27
@@ -305,6 +305,10 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||||
|
// A request belongs to the branch it was raised at. A checker sees their own branch's requests and those of the
|
||||||
|
// branches below it; Head Office, the root, sees every branch's.
|
||||||
|
where.append(" AND office_id IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)");
|
||||||
|
baseParams.add(currentUser.getOffice().getHierarchy() + "%");
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
try {
|
try {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
@@ -431,8 +435,63 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
if (!currentUser.getId().equals(clerkId)) {
|
if (!currentUser.getId().equals(clerkId)) {
|
||||||
throw apiError(403, "You can only view your own disbursement requests.");
|
throw apiError(403, "You can only view your own disbursement requests.");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
assertRequestInCallersBranch(record, currentUser);
|
||||||
}
|
}
|
||||||
return gson.toJson(record);
|
return gson.toJson(withLegBranches(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request as stored, with the branch each account leg is held at added to its entries.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The approver has to see when money moves between branches, and the stored entries carry only the account number
|
||||||
|
* and name. The branch is looked up now, from the account, rather than stored at submission: a request raised before
|
||||||
|
* this existed still shows it, and it is the branch the account is in at the moment of approval that matters. A
|
||||||
|
* leg with no account behind it (a GL leg) gets none, and a failure here leaves the entries exactly as stored, so
|
||||||
|
* the review screen never fails over it.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> withLegBranches(final Map<?, ?> record) {
|
||||||
|
final Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||||
|
record.forEach((key, value) -> out.put(String.valueOf(key), value));
|
||||||
|
final Object stored = out.get("credit_entries");
|
||||||
|
if (!(stored instanceof String) || ((String) stored).isBlank()) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final JsonArray entries = gson.fromJson((String) stored, JsonArray.class);
|
||||||
|
for (final JsonElement element : entries) {
|
||||||
|
final JsonObject entry = element.getAsJsonObject();
|
||||||
|
final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||||
|
if ("GL".equals(accountType) || !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final Map<String, Object> branch = branchOfAccount(accountType, entry.get("savingsAccountId").getAsLong());
|
||||||
|
if (branch != null) {
|
||||||
|
entry.addProperty("officeId", ((Number) branch.get("id")).longValue());
|
||||||
|
entry.addProperty("officeName", String.valueOf(branch.get("name")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.put("credit_entries", gson.toJson(entries));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.warn("Could not add account branches to clerk request {}: {}", record.get("id"), e.getMessage());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch an account's books are in: its own office when it was opened at another branch than the client's,
|
||||||
|
* otherwise the client's (or group's). A CURRENT leg carries an id in m_current_account's id space; every other
|
||||||
|
* non-GL type is a m_savings_account row.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> branchOfAccount(final String accountType, final Long accountId) {
|
||||||
|
final String sql = "CURRENT".equalsIgnoreCase(accountType)
|
||||||
|
? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id "
|
||||||
|
+ "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?"
|
||||||
|
: "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id "
|
||||||
|
+ "LEFT JOIN m_group g ON g.id = sa.group_id "
|
||||||
|
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
|
||||||
|
return jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
|
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
|
||||||
@@ -448,6 +507,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record, currentUser);
|
||||||
|
|
||||||
if (!"PENDING".equals(record.get("status"))) {
|
if (!"PENDING".equals(record.get("status"))) {
|
||||||
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
|
||||||
@@ -578,21 +638,23 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
"No savings-control GL mapping found for account " + acNo
|
"No savings-control GL mapping found for account " + acNo
|
||||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||||
|
|
||||||
|
// Each leg is booked in the branch its own account is held at, not the request's branch.
|
||||||
|
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
|
||||||
if ("Dr".equals(dbCr)) {
|
if ("Dr".equals(dbCr)) {
|
||||||
final long txnId = isCurrent
|
final long txnId = isCurrent
|
||||||
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
? directDebitCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
|
||||||
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
|
btDebitGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId));
|
||||||
btTotalDr = btTotalDr.add(entryAmt);
|
btTotalDr = btTotalDr.add(entryAmt);
|
||||||
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
|
||||||
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
|
||||||
} else {
|
} else {
|
||||||
final long txnId = isCurrent
|
final long txnId = isCurrent
|
||||||
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
|
? directCreditCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
|
||||||
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
|
: directCreditSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
|
btCreditGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId));
|
||||||
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
|
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
|
||||||
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
|
||||||
}
|
}
|
||||||
@@ -602,13 +664,10 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) {
|
if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) {
|
||||||
final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description)
|
final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description)
|
||||||
+ " → " + buildGlEntryComment("", btCreditGlEntries, null);
|
+ " → " + buildGlEntryComment("", btCreditGlEntries, null);
|
||||||
final String btGlTxnId = postCombinedJournalEntry(
|
final String btGlTxnId = postBankTransferJournals(
|
||||||
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment);
|
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
|
||||||
if (btGlTxnId != null) {
|
// The request row keeps 60 characters; an inter-branch transfer posts one entry per branch.
|
||||||
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2);
|
approvedGlTxnId = btGlTxnId == null || btGlTxnId.length() <= 60 ? btGlTxnId : btGlTxnId.split(",")[0];
|
||||||
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
|
|
||||||
}
|
|
||||||
approvedGlTxnId = btGlTxnId;
|
|
||||||
|
|
||||||
// Audit log in m_bank_transfer_log
|
// Audit log in m_bank_transfer_log
|
||||||
final String btTxnRefsStr = String.join(",", txnRefs);
|
final String btTxnRefsStr = String.join(",", txnRefs);
|
||||||
@@ -855,6 +914,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final AppUser currentUser = context.authenticatedUser();
|
final AppUser currentUser = context.authenticatedUser();
|
||||||
validateCheckerRole(currentUser);
|
validateCheckerRole(currentUser);
|
||||||
final Map<?, ?> record = loadRecord(id);
|
final Map<?, ?> record = loadRecord(id);
|
||||||
|
assertRequestInCallersBranch(record, currentUser);
|
||||||
|
|
||||||
// Maker-checker: the person who submitted the request cannot also reject it.
|
// Maker-checker: the person who submitted the request cannot also reject it.
|
||||||
final Long makerId = record.get("clerk_id") != null
|
final Long makerId = record.get("clerk_id") != null
|
||||||
@@ -1005,12 +1065,14 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
"No savings-control GL mapping found for account " + acNo
|
"No savings-control GL mapping found for account " + acNo
|
||||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||||
|
|
||||||
|
// Each leg is booked in the branch its own account is held at, not the request's branch.
|
||||||
|
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
|
||||||
final long txnId = isCurrent
|
final long txnId = isCurrent
|
||||||
? directDebitCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
? directDebitCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
|
||||||
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
|
: directDebitSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description);
|
||||||
|
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
|
debitGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId));
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -1032,11 +1094,12 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
"No savings-control GL mapping found for account " + acNo
|
"No savings-control GL mapping found for account " + acNo
|
||||||
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
|
||||||
|
|
||||||
|
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
|
||||||
final long txnId = isCurrent
|
final long txnId = isCurrent
|
||||||
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
|
? directCreditCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
|
||||||
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
|
: directCreditSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser);
|
||||||
txnRefs.add(String.valueOf(txnId));
|
txnRefs.add(String.valueOf(txnId));
|
||||||
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
|
creditGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId));
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
|
||||||
} else {
|
} else {
|
||||||
@@ -1047,13 +1110,8 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
// ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─
|
// ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─
|
||||||
final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description)
|
final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description)
|
||||||
+ " → " + buildGlEntryComment("", creditGlEntries, null);
|
+ " → " + buildGlEntryComment("", creditGlEntries, null);
|
||||||
glTxnId = postCombinedJournalEntry(
|
glTxnId = postBankTransferJournals(
|
||||||
debitGlEntries, creditGlEntries,
|
debitGlEntries, creditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
|
||||||
officeId, currencyCode, txnDateStr, btComment);
|
|
||||||
if (glTxnId != null) {
|
|
||||||
linkGlEntriesToSavingsAccounts(glTxnId, debitGlEntries, 2);
|
|
||||||
linkGlEntriesToSavingsAccounts(glTxnId, creditGlEntries, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
for (int i = undoOps.size() - 1; i >= 0; i--) {
|
for (int i = undoOps.size() - 1; i >= 0; i--) {
|
||||||
@@ -1446,6 +1504,28 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request belongs to the branch it was raised at, and is the business of that branch's checker, not another's.
|
||||||
|
* A checker may see and act on requests of their own branch and the branches below it; Head Office, as the root,
|
||||||
|
* reaches every branch. Anything outside that is reported as not found rather than forbidden, so it does not even
|
||||||
|
* confirm the request exists. A request with no branch on record belongs to the root.
|
||||||
|
*/
|
||||||
|
private void assertRequestInCallersBranch(final Map<?, ?> record, final AppUser user) {
|
||||||
|
final String callerHierarchy = user.getOffice().getHierarchy();
|
||||||
|
final Object officeId = record.get("office_id");
|
||||||
|
boolean visible;
|
||||||
|
if (officeId == null) {
|
||||||
|
visible = ".".equals(callerHierarchy);
|
||||||
|
} else {
|
||||||
|
final String requestHierarchy = jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class,
|
||||||
|
((Number) officeId).longValue()).stream().findFirst().orElse(null);
|
||||||
|
visible = requestHierarchy != null && requestHierarchy.startsWith(callerHierarchy);
|
||||||
|
}
|
||||||
|
if (!visible) {
|
||||||
|
throw new NotFoundException("Disbursement request not found: " + record.get("id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
|
||||||
@@ -1690,6 +1770,8 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
|
||||||
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
|
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
|
||||||
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
|
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
|
||||||
|
/** The branch this leg's account is held at; null where it was not resolved (the request's branch is then used). */
|
||||||
|
Long officeId;
|
||||||
|
|
||||||
GlEntry(final Long glAccountId, final BigDecimal amount,
|
GlEntry(final Long glAccountId, final BigDecimal amount,
|
||||||
final String accountRef, final String txnId) {
|
final String accountRef, final String txnId) {
|
||||||
@@ -2123,6 +2205,105 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return result != null ? result.getTransactionId() : null;
|
return result != null ? result.getTransactionId() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Financial Activity "Liability Transfer": the account that links branches' books on a transfer between them. */
|
||||||
|
private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200;
|
||||||
|
|
||||||
|
/** The branch an account's books are in, or {@code fallbackOfficeId} where it cannot be resolved. */
|
||||||
|
private Long legOfficeOf(final boolean isCurrent, final Long accountId, final Long fallbackOfficeId) {
|
||||||
|
final Map<String, Object> branch = branchOfAccount(isCurrent ? "CURRENT" : "SAVINGS", accountId);
|
||||||
|
return branch != null ? ((Number) branch.get("id")).longValue() : fallbackOfficeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GlEntry withOffice(final GlEntry entry, final Long officeId) {
|
||||||
|
entry.officeId = officeId;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long liabilityTransferGlId() {
|
||||||
|
final List<Long> ids = jdbcTemplate.queryForList(
|
||||||
|
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?",
|
||||||
|
Long.class, FINANCIAL_ACTIVITY_LIABILITY_TRANSFER);
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
throw new RuntimeException("A transfer between branches needs the Liability Transfer account mapped under "
|
||||||
|
+ "Accounting > Financial Activity Mappings.");
|
||||||
|
}
|
||||||
|
return ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal totalOf(final List<GlEntry> entries) {
|
||||||
|
BigDecimal total = BigDecimal.ZERO;
|
||||||
|
for (final GlEntry entry : entries) {
|
||||||
|
total = total.add(entry.amount);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts the ledger side of a bank transfer and returns its transaction id, or the ids comma-separated when more than
|
||||||
|
* one entry is needed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Every leg belongs to the branch its own account is held at. When they all share one branch this is the single
|
||||||
|
* combined entry it always was, booked in that branch. When the legs span branches, each branch gets its own entry,
|
||||||
|
* balanced through the Liability Transfer account: a branch that pays out more than it takes in credits the difference
|
||||||
|
* to it, one that takes in more debits it. The two sides net to zero across the bank, so each branch's books balance
|
||||||
|
* by themselves and the bank's total does not move. Each entry registers its own undo, so a failure later in the
|
||||||
|
* transfer rolls the ledger back with the accounts.
|
||||||
|
*/
|
||||||
|
private String postBankTransferJournals(final List<GlEntry> debits, final List<GlEntry> credits, final Long fallbackOfficeId,
|
||||||
|
final String currencyCode, final String txnDateStr, final String comment, final List<Runnable> undoOps) {
|
||||||
|
final Map<Long, List<GlEntry>> debitsByOffice = new java.util.LinkedHashMap<>();
|
||||||
|
final Map<Long, List<GlEntry>> creditsByOffice = new java.util.LinkedHashMap<>();
|
||||||
|
for (final GlEntry debit : debits) {
|
||||||
|
debitsByOffice.computeIfAbsent(debit.officeId != null ? debit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(debit);
|
||||||
|
}
|
||||||
|
for (final GlEntry credit : credits) {
|
||||||
|
creditsByOffice.computeIfAbsent(credit.officeId != null ? credit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(credit);
|
||||||
|
}
|
||||||
|
final Set<Long> offices = new LinkedHashSet<>(debitsByOffice.keySet());
|
||||||
|
offices.addAll(creditsByOffice.keySet());
|
||||||
|
|
||||||
|
final List<String> txnIds = new ArrayList<>();
|
||||||
|
if (offices.size() == 1) {
|
||||||
|
postBranchEntry(debits, credits, offices.iterator().next(), currencyCode, txnDateStr, comment, undoOps, txnIds);
|
||||||
|
} else {
|
||||||
|
final Long suspenseGlId = liabilityTransferGlId();
|
||||||
|
for (final Long office : offices) {
|
||||||
|
final List<GlEntry> branchDebits = new ArrayList<>(debitsByOffice.getOrDefault(office, List.of()));
|
||||||
|
final List<GlEntry> branchCredits = new ArrayList<>(creditsByOffice.getOrDefault(office, List.of()));
|
||||||
|
final BigDecimal net = totalOf(branchCredits).subtract(totalOf(branchDebits));
|
||||||
|
if (net.signum() > 0) {
|
||||||
|
branchDebits.add(new GlEntry(suspenseGlId, net, "Inter-branch", null));
|
||||||
|
} else if (net.signum() < 0) {
|
||||||
|
branchCredits.add(new GlEntry(suspenseGlId, net.negate(), "Inter-branch", null));
|
||||||
|
}
|
||||||
|
postBranchEntry(branchDebits, branchCredits, office, currencyCode, txnDateStr,
|
||||||
|
comment + " | Inter-branch, via Liability Transfer", undoOps, txnIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return txnIds.isEmpty() ? null : String.join(",", txnIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postBranchEntry(final List<GlEntry> debits, final List<GlEntry> credits, final Long officeId, final String currencyCode,
|
||||||
|
final String txnDateStr, final String comment, final List<Runnable> undoOps, final List<String> txnIds) {
|
||||||
|
final String glTxnId = postCombinedJournalEntry(debits, credits, officeId, currencyCode, txnDateStr, comment);
|
||||||
|
if (glTxnId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
linkGlEntriesToSavingsAccounts(glTxnId, debits, 2);
|
||||||
|
linkGlEntriesToSavingsAccounts(glTxnId, credits, 1);
|
||||||
|
txnIds.add(glTxnId);
|
||||||
|
undoOps.add(() -> reverseLedgerEntry(glTxnId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverses a ledger entry this request posted, when a later step of the same transfer fails. */
|
||||||
|
private void reverseLedgerEntry(final String glTxnId) {
|
||||||
|
final CommandWrapper cmd = new CommandWrapperBuilder().reverseJournalEntry(glTxnId)
|
||||||
|
.withJson("{\"comments\":\"Bank transfer rolled back\"}").build();
|
||||||
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
|
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds the GL entry comments string listing all affected account numbers. */
|
/** Builds the GL entry comments string listing all affected account numbers. */
|
||||||
private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
|
private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
|
||||||
final String description) {
|
final String description) {
|
||||||
|
|||||||
+23
-2
@@ -351,8 +351,8 @@ public class ClerkClosureRequestApiResource {
|
|||||||
@QueryParam("toDate") final String toDate,
|
@QueryParam("toDate") final String toDate,
|
||||||
@QueryParam("search") final String search) {
|
@QueryParam("search") final String search) {
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
baseParams.add(Date.valueOf(fromDate));
|
baseParams.add(Date.valueOf(fromDate));
|
||||||
@@ -1071,7 +1071,28 @@ public class ClerkClosureRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
|
||||||
|
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
|
||||||
|
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
|
||||||
|
|
||||||
|
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
|
||||||
|
private void assertMakerInCallersBranch(final Map<?, ?> record) {
|
||||||
|
final Object makerId = record.get("clerk_id");
|
||||||
|
final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
|
||||||
|
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
|
||||||
|
if (visible == null || visible == 0) {
|
||||||
|
throw new NotFoundException("Request not found: " + record.get("id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
|
final Map<?, ?> record = loadRecordUnscoped(id);
|
||||||
|
assertMakerInCallersBranch(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<?, ?> loadRecordUnscoped(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, savings_account_id, account_no, client_id, client_name, closure_date,
|
SELECT id, savings_account_id, account_no, client_id, client_name, closure_date,
|
||||||
payment_type_id, payment_type_name, description, tok_no, status,
|
payment_type_id, payment_type_name, description, tok_no, status,
|
||||||
|
|||||||
+25
-1
@@ -265,7 +265,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||||
@QueryParam("search") final String search) {
|
@QueryParam("search") final String search) {
|
||||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit);
|
return queryList("WHERE status = ? AND " + MAKER_IN_HIERARCHY,
|
||||||
|
new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%")), fromDate, toDate, search,
|
||||||
|
offset, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
||||||
@@ -1441,7 +1443,29 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
|
||||||
|
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
|
||||||
|
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
|
||||||
|
|
||||||
|
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
|
||||||
|
private void assertMakerInCallersBranch(final Map<?, ?> record) {
|
||||||
|
final Object makerId = record.get("clerk_id");
|
||||||
|
final Integer visible = makerId == null ? 0
|
||||||
|
: jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
|
||||||
|
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
|
||||||
|
if (visible == null || visible == 0) {
|
||||||
|
throw new NotFoundException("Request not found: " + record.get("id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
|
final Map<?, ?> record = loadRecordUnscoped(id);
|
||||||
|
assertMakerInCallersBranch(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<?, ?> loadRecordUnscoped(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate
|
final List<Map<String, Object>> rows = jdbcTemplate
|
||||||
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
|
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
|
|||||||
+24
-2
@@ -188,8 +188,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
@QueryParam("toDate") final String toDate,
|
@QueryParam("toDate") final String toDate,
|
||||||
@QueryParam("search") final String search) {
|
@QueryParam("search") final String search) {
|
||||||
|
|
||||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
|
||||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
|
||||||
if (fromDate != null && !fromDate.isBlank()) {
|
if (fromDate != null && !fromDate.isBlank()) {
|
||||||
where.append(" AND DATE(submitted_at) >= ?");
|
where.append(" AND DATE(submitted_at) >= ?");
|
||||||
baseParams.add(Date.valueOf(fromDate));
|
baseParams.add(Date.valueOf(fromDate));
|
||||||
@@ -308,6 +308,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
throw new NotFoundException("Withdrawal request not found: " + id);
|
throw new NotFoundException("Withdrawal request not found: " + id);
|
||||||
}
|
}
|
||||||
|
assertMakerInCallersBranch(rows.get(0));
|
||||||
return gson.toJson(rows.get(0));
|
return gson.toJson(rows.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,7 +943,28 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
.orElse(Map.of());
|
.orElse(Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
|
||||||
|
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
|
||||||
|
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
|
||||||
|
|
||||||
|
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
|
||||||
|
private void assertMakerInCallersBranch(final Map<?, ?> record) {
|
||||||
|
final Object makerId = record.get("clerk_id");
|
||||||
|
final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
|
||||||
|
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
|
||||||
|
if (visible == null || visible == 0) {
|
||||||
|
throw new NotFoundException("Request not found: " + record.get("id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Map<?, ?> loadRecord(final Long id) {
|
private Map<?, ?> loadRecord(final Long id) {
|
||||||
|
final Map<?, ?> record = loadRecordUnscoped(id);
|
||||||
|
assertMakerInCallersBranch(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<?, ?> loadRecordUnscoped(final Long id) {
|
||||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||||
amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,
|
amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,
|
||||||
|
|||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* 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.client.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
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.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.client.service.ClientIdentityService;
|
||||||
|
import org.apache.fineract.portfolio.client.service.ClientIdentityService.IdentityMatch;
|
||||||
|
import org.apache.fineract.portfolio.savings.security.AccountBranchService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Is this person already a customer of the bank?", asked from the General step of client creation before anything is
|
||||||
|
* saved. See {@link ClientIdentityService} for the rules.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* It is a plain POST resource rather than a command, so the request body is not written to the command-source audit
|
||||||
|
* table. The Aadhaar arrives only as its SHA-256; the 12-digit number never reaches the server.
|
||||||
|
*/
|
||||||
|
@Path("/v1/clientidentity")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Client Identity", description = "Cross-branch duplicate-customer lookup by PAN, Aadhaar fingerprint or GSTIN.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ClientIdentityLookupApiResource {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ClientIdentityLookupApiResource.class);
|
||||||
|
private static final Gson GSON = new Gson();
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ClientIdentityService clientIdentityService;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final AccountBranchService accountBranchService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Introduces a customer of another branch to the caller's branch, so the branch can open an account for them
|
||||||
|
* there. The customer stays at the home branch; this records who introduced them to which branch and when.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("link")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Link an existing customer of another branch to the caller's branch",
|
||||||
|
description = "Body: { clientId }. Idempotent. Lets the caller's branch open an account for the customer.")
|
||||||
|
public String link(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "CREATE_SAVINGSACCOUNT")) {
|
||||||
|
throw new NoAuthorizationException("The user doesn't have enough permissions to link a customer to a branch.");
|
||||||
|
}
|
||||||
|
final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}"));
|
||||||
|
final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
|
||||||
|
if (!body.has("clientId") || body.get("clientId").isJsonNull()) {
|
||||||
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("error.msg.clientidentity.clientId.required",
|
||||||
|
"clientId is required.", "clientId")));
|
||||||
|
}
|
||||||
|
final Long clientId = body.get("clientId").getAsLong();
|
||||||
|
final boolean newlyLinked = this.accountBranchService.linkClientToCallerBranch(clientId);
|
||||||
|
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("clientId", clientId);
|
||||||
|
response.put("linked", newlyLinked);
|
||||||
|
response.put("officeId", this.context.authenticatedUser().getOffice().getId());
|
||||||
|
response.put("officeName", this.context.authenticatedUser().getOffice().getName());
|
||||||
|
return GSON.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("lookup")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Look up an existing customer by PAN, Aadhaar hash or GSTIN, and check phone/email clashes",
|
||||||
|
description = "Body: { panNumber?, aadhaarHash?, gstinNumber?, mobileNo?, emailAddress?, excludeClientId? }. "
|
||||||
|
+ "Any ONE of PAN / Aadhaar hash / GSTIN matching an existing client means the same customer. A phone or "
|
||||||
|
+ "email clash is reported separately under contactConflicts and is never the same customer.")
|
||||||
|
public String lookup(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "UPDATE_CLIENT")) {
|
||||||
|
throw new NoAuthorizationException("The user doesn't have enough permissions to look up customers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}"));
|
||||||
|
final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
|
||||||
|
|
||||||
|
final String pan = text(body, "panNumber");
|
||||||
|
final String aadhaarHash = text(body, "aadhaarHash");
|
||||||
|
final String gstin = text(body, "gstinNumber");
|
||||||
|
final String mobileNo = text(body, "mobileNo");
|
||||||
|
final String emailAddress = text(body, "emailAddress");
|
||||||
|
final Long excludeClientId = body.has("excludeClientId") && !body.get("excludeClientId").isJsonNull()
|
||||||
|
? body.get("excludeClientId").getAsLong() : null;
|
||||||
|
|
||||||
|
ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHash);
|
||||||
|
|
||||||
|
final List<IdentityMatch> matches = this.clientIdentityService.findMatches(pan, aadhaarHash, gstin, excludeClientId);
|
||||||
|
final Map<String, Boolean> contactConflicts = this.clientIdentityService.contactConflicts(mobileNo, emailAddress,
|
||||||
|
excludeClientId);
|
||||||
|
|
||||||
|
// Who looked up what, never the values: a lookup that finds a customer at another branch is exactly what
|
||||||
|
// someone browsing for customers would do, so there has to be a trail.
|
||||||
|
LOG.info("client identity lookup: user={} fields={} matchedClientIds={}", this.context.authenticatedUser().getUsername(),
|
||||||
|
suppliedFields(pan, aadhaarHash, gstin, mobileNo, emailAddress), matches.stream().map(IdentityMatch::clientId).toList());
|
||||||
|
|
||||||
|
final Set<Long> distinctClients = new LinkedHashSet<>();
|
||||||
|
matches.forEach(m -> distinctClients.add(m.clientId()));
|
||||||
|
|
||||||
|
final Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("matched", !matches.isEmpty());
|
||||||
|
// PAN points at one customer and Aadhaar (or GSTIN) at another: bad data or fraud, not a customer to reuse.
|
||||||
|
response.put("conflict", distinctClients.size() > 1);
|
||||||
|
response.put("matches", toJsonList(matches));
|
||||||
|
response.put("contactConflicts", contactConflicts);
|
||||||
|
return GSON.toJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(final JsonObject body, final String name) {
|
||||||
|
final JsonElement element = body.get(name);
|
||||||
|
return element == null || element.isJsonNull() ? null : StringUtils.trimToNull(element.getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> suppliedFields(final String pan, final String aadhaarHash, final String gstin, final String mobileNo,
|
||||||
|
final String emailAddress) {
|
||||||
|
final List<String> fields = new ArrayList<>();
|
||||||
|
if (pan != null) {
|
||||||
|
fields.add("pan");
|
||||||
|
}
|
||||||
|
if (aadhaarHash != null) {
|
||||||
|
fields.add("aadhaar");
|
||||||
|
}
|
||||||
|
if (gstin != null) {
|
||||||
|
fields.add("gstin");
|
||||||
|
}
|
||||||
|
if (mobileNo != null) {
|
||||||
|
fields.add("mobile");
|
||||||
|
}
|
||||||
|
if (emailAddress != null) {
|
||||||
|
fields.add("email");
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Map<String, Object>> toJsonList(final List<IdentityMatch> matches) {
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final IdentityMatch m : matches) {
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("clientId", m.clientId());
|
||||||
|
row.put("displayName", m.displayName());
|
||||||
|
row.put("cifNumber", m.cifNumber());
|
||||||
|
row.put("officeId", m.officeId());
|
||||||
|
row.put("officeName", m.officeName());
|
||||||
|
row.put("legalFormEnum", m.legalFormEnum());
|
||||||
|
row.put("kycStatus", m.kycStatus());
|
||||||
|
// True when the caller's branch can open this client; false when it sits in another branch's hierarchy.
|
||||||
|
row.put("visibleToCaller", m.visibleToCaller());
|
||||||
|
row.put("panMasked", m.panMasked());
|
||||||
|
row.put("aadhaarMasked", m.aadhaarMasked());
|
||||||
|
row.put("gstinMasked", m.gstinMasked());
|
||||||
|
row.put("matchedBy", new ArrayList<>(m.matchedBy()));
|
||||||
|
out.add(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-1
@@ -28,6 +28,7 @@ import jakarta.ws.rs.GET;
|
|||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -36,6 +37,7 @@ import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSer
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
import org.apache.fineract.portfolio.client.data.KycContactLogData;
|
||||||
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
import org.apache.fineract.portfolio.client.service.KycContactLogService;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycNoticeComposer;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,6 +67,7 @@ public class ClientKycContactLogApiResource {
|
|||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final KycContactLogService kycContactLogService;
|
private final KycContactLogService kycContactLogService;
|
||||||
|
private final KycNoticeComposer noticeComposer;
|
||||||
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -79,11 +82,31 @@ public class ClientKycContactLogApiResource {
|
|||||||
|
|
||||||
final JsonObject payload = parse(apiRequestBodyAsJson);
|
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||||
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
|
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
|
||||||
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
|
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"), stringOrNull(payload, "noticeType"));
|
||||||
|
|
||||||
return this.toApiJsonSerializer.serialize(result);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("preview")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Preview the notice a customer would receive",
|
||||||
|
description = "The subject and text for the stage the customer is at today. type is KYC or DORMANCY. "
|
||||||
|
+ "Empty subject and body when the customer is at no stage of that kind.")
|
||||||
|
public String previewNotice(@PathParam("clientId") final Long clientId, @QueryParam("type") final String type) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final KycNoticeComposer.KycNotice notice = this.noticeComposer.compose(clientId, type);
|
||||||
|
final JsonObject json = new JsonObject();
|
||||||
|
if (notice != null) {
|
||||||
|
final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(notice);
|
||||||
|
json.addProperty("subject", delivery.subject());
|
||||||
|
json.addProperty("body", delivery.body());
|
||||||
|
}
|
||||||
|
return json.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
|||||||
+20
@@ -89,4 +89,24 @@ public class ClientKycExceptionsApiResource {
|
|||||||
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
|
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
|
||||||
return this.toApiJsonSerializer.serialize(lapsed);
|
return this.toApiJsonSerializer.serialize(lapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customers whose periodic re-KYC falls due within the next {@code days} days (default 30).
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The counterpart of {@code lapsed}: nothing has stopped working for these customers yet, which is
|
||||||
|
* why they are worth reaching now.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("expiring")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve KYC records expiring soon", description = "Returns clients whose periodic re-KYC falls due within the next N days (default 30), soonest first.")
|
||||||
|
public String retrieveExpiringKyc(@QueryParam("days") final Integer days) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
final int window = days == null ? 30 : days;
|
||||||
|
final List<ClientKycData> expiring = this.readPlatformService.retrieveExpiringKyc(window);
|
||||||
|
LOG.info("retrieveExpiringKyc: fetched {} KYC record(s) expiring within {} day(s)", expiring.size(), window);
|
||||||
|
return this.toApiJsonSerializer.serialize(expiring);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 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.client.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
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.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every customer notice on record, across all customers, for audit.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* GET /v1/kyc/contact-audit/days one line per day: how many customers and notices, and how they fared
|
||||||
|
* GET /v1/kyc/contact-audit the notices themselves, a page at a time: {"total": n, "rows": [...]}
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Both take the same optional filters: {@code from} and {@code to} (yyyy-MM-dd, both inclusive),
|
||||||
|
* {@code channel}, {@code status} and {@code search} (customer name, account number or reference). The
|
||||||
|
* per-customer contact log answers "what was this customer told"; this answers "what did the bank send,
|
||||||
|
* to whom, and did it arrive" — the question an inspection asks.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Built for a bank with a very large number of customers: the day view is computed in the database and
|
||||||
|
* is a few hundred rows at most, and the notices of one day are fetched a page at a time, so nothing
|
||||||
|
* here ever loads the whole log.
|
||||||
|
*/
|
||||||
|
@Path("/v1/kyc/contact-audit")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Client KYC Contact Audit", description = "All customer notices on record.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class KycContactAuditApiResource {
|
||||||
|
|
||||||
|
private static final int MAX_PAGE = 500;
|
||||||
|
private static final int MAX_DAYS = 400;
|
||||||
|
|
||||||
|
private static final String FROM_CLAUSE = " FROM m_client_kyc_contact_log l JOIN m_client c ON c.id = l.client_id WHERE 1 = 1";
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("days")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.")
|
||||||
|
public String days(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||||
|
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||||
|
@QueryParam("search") final String search) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
final String where = filters(from, to, channel, status, search, args);
|
||||||
|
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
|
||||||
|
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
|
||||||
|
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
|
||||||
|
+ "SUM(CASE WHEN l.delivery_status = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, "
|
||||||
|
+ "SUM(CASE WHEN l.delivery_status = 'PROVIDER_NOT_CONFIGURED' THEN 1 ELSE 0 END) AS not_configured, "
|
||||||
|
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded" + FROM_CLAUSE + where
|
||||||
|
+ " GROUP BY CAST(l.contacted_at AS DATE) ORDER BY d DESC LIMIT " + MAX_DAYS;
|
||||||
|
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||||
|
final Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("date", String.valueOf(r.get("d")));
|
||||||
|
item.put("notices", number(r.get("notices")));
|
||||||
|
item.put("customers", number(r.get("customers")));
|
||||||
|
item.put("delivered", number(r.get("delivered")));
|
||||||
|
item.put("failed", number(r.get("failed")));
|
||||||
|
item.put("noContact", number(r.get("no_contact")));
|
||||||
|
item.put("notConfigured", number(r.get("not_configured")));
|
||||||
|
item.put("recorded", number(r.get("recorded")));
|
||||||
|
out.add(item);
|
||||||
|
}
|
||||||
|
return this.gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.")
|
||||||
|
public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||||
|
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||||
|
@QueryParam("search") final String search, @QueryParam("offset") final Integer offset,
|
||||||
|
@QueryParam("limit") final Integer limit) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final List<Object> args = new ArrayList<>();
|
||||||
|
final String where = filters(from, to, channel, status, search, args);
|
||||||
|
|
||||||
|
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray());
|
||||||
|
|
||||||
|
final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE);
|
||||||
|
final int skip = Math.max(offset == null ? 0 : offset, 0);
|
||||||
|
final String sql = "SELECT l.id, l.client_id, c.display_name, c.account_no, l.channel, l.notes, l.officer_name, "
|
||||||
|
+ "l.delivery_status, l.destination_masked, l.provider, l.provider_reference, l.failure_reason, l.message_body, "
|
||||||
|
+ "l.contacted_at" + FROM_CLAUSE + where + " ORDER BY l.contacted_at DESC, l.id DESC LIMIT " + rows + " OFFSET " + skip;
|
||||||
|
|
||||||
|
final List<Map<String, Object>> list = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||||
|
final Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("id", r.get("id"));
|
||||||
|
item.put("clientId", r.get("client_id"));
|
||||||
|
item.put("clientName", r.get("display_name"));
|
||||||
|
item.put("accountNo", r.get("account_no"));
|
||||||
|
item.put("channel", r.get("channel"));
|
||||||
|
item.put("notes", r.get("notes"));
|
||||||
|
item.put("officerName", r.get("officer_name"));
|
||||||
|
item.put("deliveryStatus", r.get("delivery_status"));
|
||||||
|
item.put("destinationMasked", r.get("destination_masked"));
|
||||||
|
item.put("provider", r.get("provider"));
|
||||||
|
item.put("providerReference", r.get("provider_reference"));
|
||||||
|
item.put("failureReason", r.get("failure_reason"));
|
||||||
|
item.put("messageBody", r.get("message_body"));
|
||||||
|
final Object at = r.get("contacted_at");
|
||||||
|
item.put("contactedAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
|
||||||
|
list.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("total", total == null ? 0 : total);
|
||||||
|
out.put("rows", list);
|
||||||
|
return this.gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */
|
||||||
|
private String filters(final String from, final String to, final String channel, final String status, final String search,
|
||||||
|
final List<Object> args) {
|
||||||
|
final StringBuilder where = new StringBuilder();
|
||||||
|
if (StringUtils.isNotBlank(from)) {
|
||||||
|
where.append(" AND l.contacted_at >= ?");
|
||||||
|
args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay()));
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(to)) {
|
||||||
|
where.append(" AND l.contacted_at < ?");
|
||||||
|
args.add(Timestamp.valueOf(parse(to, "to").plusDays(1).atStartOfDay()));
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(channel)) {
|
||||||
|
where.append(" AND l.channel = ?");
|
||||||
|
args.add(channel.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(status)) {
|
||||||
|
where.append(" AND l.delivery_status = ?");
|
||||||
|
args.add(status.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(search)) {
|
||||||
|
final String like = "%" + search.trim().toLowerCase() + "%";
|
||||||
|
where.append(" AND (LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(l.provider_reference) LIKE ?)");
|
||||||
|
args.add(like);
|
||||||
|
args.add(like);
|
||||||
|
args.add(like);
|
||||||
|
}
|
||||||
|
return where.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long number(final Object value) {
|
||||||
|
return value == null ? 0 : ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate parse(final String value, final String parameter) {
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(value.trim());
|
||||||
|
} catch (final DateTimeParseException e) {
|
||||||
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||||
|
"validation.msg.kyc.contact.audit." + parameter + ".invalid", parameter + " must look like 2028-02-17.", parameter)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* 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.client.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
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.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.EnqueueResult;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.QueueItem;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notices queued to be sent in the background.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* POST /v1/kyc/notice-queue body: {"items":[{"clientId":1,"noticeType":"KYC","channel":"SMS",
|
||||||
|
* "accountNo":"..","stageLabel":".."}],
|
||||||
|
* "notes":"..","officerName":"..","sendAt":"2028-02-17T10:00"}
|
||||||
|
* GET /v1/kyc/notice-queue?status=PENDING newest first
|
||||||
|
* POST /v1/kyc/notice-queue/{id}?command=cancel | retry
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Authorisation matches the contact log: sending a notice to a customer is a maintenance action on
|
||||||
|
* the customer's record.
|
||||||
|
*/
|
||||||
|
@Path("/v1/kyc/notice-queue")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Client KYC Notice Queue", description = "Queue customer notices to be sent in the background.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class KycNoticeQueueApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final KycNoticeQueueService queueService;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Queue notices for background sending")
|
||||||
|
public String enqueue(final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final JsonObject payload = parse(apiRequestBodyAsJson);
|
||||||
|
final List<QueueItem> items = new ArrayList<>();
|
||||||
|
final JsonElement raw = payload.get("items");
|
||||||
|
if (raw != null && raw.isJsonArray()) {
|
||||||
|
final JsonArray array = raw.getAsJsonArray();
|
||||||
|
for (final JsonElement element : array) {
|
||||||
|
if (!element.isJsonObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final JsonObject o = element.getAsJsonObject();
|
||||||
|
final String clientId = string(o, "clientId");
|
||||||
|
items.add(new QueueItem(clientId == null ? null : Long.valueOf(clientId), string(o, "noticeType"), string(o, "channel"),
|
||||||
|
string(o, "accountNo"), string(o, "stageLabel")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime sendAt = null;
|
||||||
|
final String sendAtText = string(payload, "sendAt");
|
||||||
|
if (sendAtText != null && !sendAtText.isBlank()) {
|
||||||
|
try {
|
||||||
|
sendAt = LocalDateTime.parse(sendAtText);
|
||||||
|
} catch (final DateTimeParseException e) {
|
||||||
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||||
|
"validation.msg.kyc.notice.queue.sendAt.invalid", "sendAt must look like 2028-02-17T10:00.", "sendAt")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final EnqueueResult result = this.queueService.enqueue(items, string(payload, "notes"), string(payload, "officerName"), sendAt);
|
||||||
|
final JsonObject out = new JsonObject();
|
||||||
|
out.addProperty("queued", result.queued());
|
||||||
|
out.addProperty("skipped", result.skipped());
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "List queued notices", description = "Newest first. status narrows to PENDING, SENT, FAILED or CANCELLED.")
|
||||||
|
public String list(@QueryParam("status") final String status, @QueryParam("limit") final Integer limit) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
return this.gson.toJson(this.queueService.list(status, limit == null ? 200 : limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{id}")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Cancel or retry a queued notice", description = "command=cancel withdraws a notice still waiting; "
|
||||||
|
+ "command=retry puts a failed one back in line.")
|
||||||
|
public String act(@PathParam("id") final Long id, @QueryParam("command") final String command) {
|
||||||
|
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
if ("cancel".equalsIgnoreCase(command)) {
|
||||||
|
this.queueService.cancel(id);
|
||||||
|
} else if ("retry".equalsIgnoreCase(command)) {
|
||||||
|
this.queueService.retry(id);
|
||||||
|
} else {
|
||||||
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError
|
||||||
|
.parameterError("validation.msg.kyc.notice.queue.command.invalid", "command must be cancel or retry.", "command")));
|
||||||
|
}
|
||||||
|
final JsonObject out = new JsonObject();
|
||||||
|
out.addProperty("id", id);
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject parse(final String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
final JsonElement parsed = JsonParser.parseString(json);
|
||||||
|
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String string(final JsonObject o, final String member) {
|
||||||
|
final JsonElement element = o.get(member);
|
||||||
|
return element == null || element.isJsonNull() ? null : element.getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -24,6 +24,7 @@ import jakarta.ws.rs.Consumes;
|
|||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -69,4 +70,14 @@ public class KycRiskRulesApiResource {
|
|||||||
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
||||||
return this.toApiJsonSerializer.serialize(rule);
|
return this.toApiJsonSerializer.serialize(rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{ruleId}/mark-normal")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Mark a rule as normal", description = "Ends the trial of a newly added rule. It stays in the catalog as a normal rule.")
|
||||||
|
public String markNormal(@PathParam("ruleId") final Long ruleId) {
|
||||||
|
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
return this.toApiJsonSerializer.serialize(this.writePlatformService.markNormal(ruleId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -33,6 +33,8 @@ public final class ClientKycApiConstants {
|
|||||||
public static final String riskCategoryParam = "riskCategory";
|
public static final String riskCategoryParam = "riskCategory";
|
||||||
public static final String panNumberParam = "panNumber";
|
public static final String panNumberParam = "panNumber";
|
||||||
public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked";
|
public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked";
|
||||||
|
/** SHA-256 of the full Aadhaar, computed client-side. The 12-digit number itself is never sent on create/update. */
|
||||||
|
public static final String aadhaarHashParam = "aadhaarHash";
|
||||||
public static final String gstinNumberParam = "gstinNumber";
|
public static final String gstinNumberParam = "gstinNumber";
|
||||||
public static final String remarksParam = "remarks";
|
public static final String remarksParam = "remarks";
|
||||||
public static final String ckycNumberParam = "ckycNumber";
|
public static final String ckycNumberParam = "ckycNumber";
|
||||||
@@ -118,7 +120,7 @@ public final class ClientKycApiConstants {
|
|||||||
|
|
||||||
// ── Supported parameters for initiate/update ──────────────────────────────
|
// ── Supported parameters for initiate/update ──────────────────────────────
|
||||||
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||||
kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam,
|
kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam,
|
||||||
gstinNumberParam, remarksParam, "locale", "dateFormat",
|
gstinNumberParam, remarksParam, "locale", "dateFormat",
|
||||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||||
@@ -136,7 +138,7 @@ public final class ClientKycApiConstants {
|
|||||||
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
|
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
|
||||||
|
|
||||||
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam, gstinNumberParam,
|
||||||
riskCategoryParam, remarksParam, "locale", "dateFormat",
|
riskCategoryParam, remarksParam, "locale", "dateFormat",
|
||||||
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
|
||||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||||
|
|||||||
+23
-1
@@ -64,6 +64,12 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
* used in matching; a null maximum means the rule is genuinely open-ended. */
|
* used in matching; a null maximum means the rule is genuinely open-ended. */
|
||||||
private final BigDecimal txnMinLimitAmount;
|
private final BigDecimal txnMinLimitAmount;
|
||||||
private final BigDecimal txnMaxLimitAmount;
|
private final BigDecimal txnMaxLimitAmount;
|
||||||
|
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
|
||||||
|
private final boolean ccRelevant;
|
||||||
|
/** True while a newly authored rule is on trial; it is listed first until an officer marks it normal. */
|
||||||
|
private final boolean onTrial;
|
||||||
|
/** When the trial began (ISO date), or null for a rule that is not on trial. */
|
||||||
|
private final String trialStartedOn;
|
||||||
|
|
||||||
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
||||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||||
@@ -76,7 +82,8 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
||||||
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
||||||
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
||||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
|
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant,
|
||||||
|
final boolean onTrial, final String trialStartedOn) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.ruleName = ruleName;
|
this.ruleName = ruleName;
|
||||||
this.ruleKind = ruleKind;
|
this.ruleKind = ruleKind;
|
||||||
@@ -106,6 +113,9 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
|
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
|
||||||
this.txnMinLimitAmount = txnMinLimitAmount;
|
this.txnMinLimitAmount = txnMinLimitAmount;
|
||||||
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
||||||
|
this.ccRelevant = ccRelevant;
|
||||||
|
this.onTrial = onTrial;
|
||||||
|
this.trialStartedOn = trialStartedOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -223,4 +233,16 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
public BigDecimal getTxnMaxLimitAmount() {
|
public BigDecimal getTxnMaxLimitAmount() {
|
||||||
return txnMaxLimitAmount;
|
return txnMaxLimitAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isCcRelevant() {
|
||||||
|
return ccRelevant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isOnTrial() {
|
||||||
|
return onTrial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTrialStartedOn() {
|
||||||
|
return trialStartedOn;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -104,15 +104,26 @@ public class KycRiskRuleRequest {
|
|||||||
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
|
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
|
||||||
private String txnRuleType;
|
private String txnRuleType;
|
||||||
|
|
||||||
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
|
@Schema(description = "SAVINGS | LOAN | TRANSFER | CURRENT | ALL. Defaults to ALL. CURRENT is a current / Cash Credit account.", example = "ALL")
|
||||||
private String txnAppliesTo;
|
private String txnAppliesTo;
|
||||||
|
|
||||||
|
@Schema(description = "CREDIT (money in) or DEBIT (money out). Omit for either direction.", example = "CREDIT")
|
||||||
|
private String txnDirection;
|
||||||
|
|
||||||
|
@Schema(description = "True when the rule is about cash only. Defaults to false.")
|
||||||
|
private Boolean txnCashOnly;
|
||||||
|
|
||||||
|
@Schema(description = "True when the scenario watches Cash Credit accounts, so the Rules Master CC chip lists it.")
|
||||||
|
private Boolean ccRelevant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the officer has described a live-transaction rule rather than a pure KYC profile
|
* True when the officer has described a live-transaction rule rather than a pure KYC profile
|
||||||
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
|
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
|
||||||
*/
|
*/
|
||||||
public boolean hasTransactionRule() {
|
public boolean hasTransactionRule() {
|
||||||
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|
||||||
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
|
|| (this.txnWindowDays != null && this.txnWindowDays > 0)
|
||||||
|
|| (this.txnDirection != null && !this.txnDirection.isBlank()) || Boolean.TRUE.equals(this.txnCashOnly)
|
||||||
|
|| (this.txnAppliesTo != null && !this.txnAppliesTo.isBlank() && !"ALL".equalsIgnoreCase(this.txnAppliesTo.trim()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -25,6 +25,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
|
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
|
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ public class ClientRepositoryWrapper {
|
|||||||
|
|
||||||
private final ClientRepository repository;
|
private final ClientRepository repository;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Client findOneWithNotFoundDetection(final Long id) {
|
public Client findOneWithNotFoundDetection(final Long id) {
|
||||||
@@ -94,12 +96,33 @@ public class ClientRepositoryWrapper {
|
|||||||
|
|
||||||
public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) {
|
public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) {
|
||||||
Client client = this.repository.fetchByClientIdAndHierarchy(clientId, hierarchySearchString, hierarchySearchString);
|
Client client = this.repository.fetchByClientIdAndHierarchy(clientId, hierarchySearchString, hierarchySearchString);
|
||||||
|
// A customer of another branch who has been linked to the caller's branch can be seen here, so the branch can
|
||||||
|
// open an account for them. Reading only: the write paths do not go through this method.
|
||||||
|
if (client == null && isLinkedToBranchUnder(clientId, hierarchySearchString)) {
|
||||||
|
client = this.repository.findById(clientId).orElse(null);
|
||||||
|
}
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
throw new ClientNotFoundException(clientId.toString(), "client.id");
|
throw new ClientNotFoundException(clientId.toString(), "client.id");
|
||||||
}
|
}
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the client has been linked to an office at or below the given hierarchy (a LIKE pattern ending in %), or
|
||||||
|
* holds an account there. A link is what lets a branch open the customer's page to start an account; the client list
|
||||||
|
* is stricter and shows the customer only once an account exists.
|
||||||
|
*/
|
||||||
|
public boolean isLinkedToBranchUnder(final Long clientId, final String hierarchySearchString) {
|
||||||
|
final Integer count = this.jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(1) FROM ("
|
||||||
|
+ "SELECT l.client_id FROM m_client_branch_link l JOIN m_office lo ON lo.id = l.office_id WHERE l.client_id = ? AND lo.hierarchy LIKE ? "
|
||||||
|
+ "UNION ALL SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id WHERE sa.client_id = ? AND ao.hierarchy LIKE ? "
|
||||||
|
+ "UNION ALL SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id WHERE ca.client_id = ? AND co.hierarchy LIKE ?"
|
||||||
|
+ ") t",
|
||||||
|
Integer.class, clientId, hierarchySearchString, clientId, hierarchySearchString, clientId, hierarchySearchString);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public Long findIdByExternalId(final ExternalId externalId) {
|
public Long findIdByExternalId(final ExternalId externalId) {
|
||||||
return this.repository.findIdByExternalId(externalId);
|
return this.repository.findIdByExternalId(externalId);
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 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.client.jobs.processkycnoticequeue;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
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.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class ProcessKycNoticeQueueConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step processKycNoticeQueueStep(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) {
|
||||||
|
return new StepBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository)
|
||||||
|
.tasklet(processKycNoticeQueueTasklet, transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job processKycNoticeQueueJob(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) {
|
||||||
|
return new JobBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository)
|
||||||
|
.start(processKycNoticeQueueStep(processKycNoticeQueueTasklet)).incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.client.jobs.processkycnoticequeue;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService;
|
||||||
|
import org.springframework.batch.core.StepContribution;
|
||||||
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
|
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Sends the notices officers have queued, once they are due. */
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Component
|
||||||
|
public class ProcessKycNoticeQueueTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final KycNoticeQueueService kycNoticeQueueService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
|
kycNoticeQueueService.processDue();
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 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.client.jobs.sendkycreminders;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
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.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SendKycRemindersConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step sendKycRemindersStep(SendKycRemindersTasklet sendKycRemindersTasklet) {
|
||||||
|
return new StepBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).tasklet(sendKycRemindersTasklet, transactionManager)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job sendKycRemindersJob(SendKycRemindersTasklet sendKycRemindersTasklet) {
|
||||||
|
return new JobBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).start(sendKycRemindersStep(sendKycRemindersTasklet))
|
||||||
|
.incrementer(new RunIdIncrementer()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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.client.jobs.sendkycreminders;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycReminderService;
|
||||||
|
import org.springframework.batch.core.StepContribution;
|
||||||
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
|
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily pass that tells customers which stage their KYC or account has reached and what happens
|
||||||
|
* next, by SMS and email.
|
||||||
|
*/
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Component
|
||||||
|
public class SendKycRemindersTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final KycReminderService kycReminderService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
|
kycReminderService.sendReminders();
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user