Compare commits

...
Author SHA1 Message Date
niteshandClaude Opus 5.5 f016bde385 feat(cfcfrms): I4C-MHA API integration with holds, intimation, GRM and MRM
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Real NCRP-CFCFRMS integration to the I4C-MHA API Specification 2.0, alongside
the existing demo module (new paths under /v1/i4c, /v1/i4c/admin, /v1/i4c/cases).

- Inbound token and complaint APIs (Bearer / Basic / body auth, CryptoJS AES,
  IP allowlist), Section 5 / Section 7 acks, idempotent repeat pushes.
- Complaint processing: account and transaction matching, automatic hold of the
  disputed amount with prospective top-up, onward trace, Section 6 response via
  an outbox with retry, optional ack callback, API log for the periodic review.
- Holds register: 90-day review, maker-checker release (full or partial),
  freeze / unfreeze, unfreeze-and-release.
- Automatic customer intimation (email active, SMS / WhatsApp configurable)
  with manual recording and history.
- GRM grievance register (EDD, GR id, nodal, IO / DGRO / SGRO decisions,
  deadlines, escalation) and MRM restoration register (assessment, pro-rata,
  victim request and papers, bank actions, restoration to the victim through a
  restoration pool, victim-bank cases), documents with SHA-256, statements.
- Paged, filterable officer lists with date ranges; local I4C simulator.
- Migrations 0610-0613, jobs I4C Dispatch and I4C Hold Review, runbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:43:22 +05:30
niteshandClaude Opus 5.5 43c60223af feat(fixed-deposits): account-engine FD quote at opening, FD Special schedule and locked Special terms
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
PROBLEM
- The account-opening screen quoted FD maturity with a stand-alone calculator
  (calculate-fd-interest) that counts posting periods from the deposit date.
  The account engine closes them at calendar period ends (from the financial-
  year start month), so the quote differed from the account once created,
  e.g. FD DOUBLE 546,348,939 @ 6.75% for 5 years from 2026-10-21, quarterly:
  quoted 763,660,633.77, account 763,697,613.00.
- FD Special (monthly interest payout) was quoted with compounding, and its
  product could be saved with Interest Calculation Type "Compounding".

CHANGE
1. POST /fixeddepositaccounts/account-preview (FixedDepositAccountPreviewService)
   Builds the would-be account from the request exactly as submitFDApplication
   does (DepositAccountAssembler.assembleFrom, updateMaturityDateAndAmount-
   BeforeAccountActivation, applyCbsFields) inside a transaction that is
   always rolled back - nothing is saved. Returns the applied rate, maturity
   date and maturity amount Submit would store, and the interest periods from
   FixedDepositAccount#projectInterestSchedule (for FD Special: the monthly
   payout schedule, same engine as the account's Monthly Interest Payout
   Schedule). Every FD type.
   Submit's linked-account checks (linkAccountId for FD Special,
   linkedAccountId for Deposit Account Link) get a stand-in id (-1) when not
   chosen yet: both are only recorded on the account, never looked up, and
   the account is never saved. Submit still requires them.
2. FdSpecialProductTermsValidator: an FD Special product must have
   interestCalculateSelector = Simple, interestCompoundingPeriodType = Monthly
   and interestPostingPeriodType = Monthly. Checked server-side on create,
   on update (also the maker-checker approval path) and on submit-for-
   approval, so Postman / any client gets a 400 validation error per field.
   Wired through SavingsConfiguration.

FILES
  api/FixedDepositAccountsApiResource.java            (account-preview endpoint)
  service/FixedDepositAccountPreviewService.java      (new)
  data/FdSpecialProductTermsValidator.java            (new)
  service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java
  starter/SavingsConfiguration.java

NOT CHANGED
  How the account calculates interest; calculate-fd-interest still exists but
  the opening screen no longer uses it.

DATA (not in this commit)
  Existing FD Special products saved as "Compounding" are refused on their
  next edit until set to Simple (the local tenant's FD SPECIAL was updated).

VERIFIED: compileJava. Numbers above reproduced independently. Not yet run
end-to-end through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:44:47 +05:30
niteshandClaude Opus 5.5 812eafc191 fix(accounting): clear savings-to-savings transfers through the product's Transfers In Suspense GL
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
PROBLEM
"Execute RD Auto Debit" reported success but no installment moved from the
linked savings account to the RD. Every account transfer between two
savings-type accounts (savings, FD, RD) books its clearing leg to the
organisation-wide Financial Activity "Liability Transfer" (200). That
activity was not mapped on this tenant, so the journal posting threw,
the transfer rolled back, and the job only logged a WARN ("RD Auto Debit
could not collect the installment ...") per installment. The tenant had
never completed a single account transfer (m_account_transfer_details
was empty).

CHANGE
The clearing leg now comes from the product's own "Transfers In
Suspense" mapping (acc_product_mapping, financial_account_type = 10),
which is set on the product's Accounting tab and can be changed there.

GL choice for one transfer (the same GL is used for both legs, so it
nets to nil):
  1. Transfers In Suspense of the RECEIVING product, else
  2. Transfers In Suspense of the PAYING product, else
  3. Financial Activity "Liability Transfer" (previous behaviour; still
     throws if that is not mapped either).
Savings products have no Transfers In Suspense field on screen, so for
savings -> RD/FD the RD/FD product's mapping is used.

Example, RD auto debit of 2,500 (Insta Money RD, suspense = 200901):
  Dr SAVING INDIVIDUAL (savings control)  Cr LIABILITY TRANSFER SUSPENSE
  Dr LIABILITY TRANSFER SUSPENSE          Cr RD ACCOUNT (RD control)

Undo / reversal of a transfer leg reverses through the clearing GL that
leg was ORIGINALLY posted to (read from acc_gl_journal_entry by
savings_transaction_id: the credit row of a withdrawal, the debit row of
a deposit). Transfers posted before this change (Liability Transfer)
and after it (product suspense) therefore both reverse to the account
they hit, even if the product mapping is changed later.

FILES
AccountingProcessorHelper
  - TRANSFER_CLEARING_ACCOUNT (ThreadLocal<GLAccount>)
  - useProductTransferSuspense(toProductId, fromProductId)
  - useClearingAccountPostedFor(savingsTransactionId, isWithdrawal)
  - clearProductTransferSuspense()
  - getLinkedGLAccountForSavingsProduct(): returns the thread's clearing
    account when FinancialActivity.LIABILITY_TRANSFER is asked for and
    one is set; otherwise unchanged.
AccountTransfersWritePlatformServiceImpl
  - transferFunds(), savings -> savings branch: handleWithdrawal and
    handleDeposit run inside useProductTransferSuspense / finally clear.
  - create() (Account Transfer screen/API), savings -> savings: same.
    The to-account is now assembled before the withdrawal (was after).
  - undoTransactions(): each savings leg is undone inside
    useClearingAccountPostedFor / finally clear.

FLOWS AFFECTED (savings-type <-> savings-type, via transferFunds/create)
  - RD Auto Debit job, RD activation from linked savings
  - FD activation funded from savings
  - FD maturity / premature / closure / payment paid to savings
  - RD closure / maturity payout to savings
  - Transfer Interest To Savings job (FD/RD interest)
  - Standing instructions and Account Transfer, savings -> savings/FD/RD
  - Fund transfer internal strategy and SuspenseGlService (savings -> savings)
  - Undo of any of the above (reverseTransfersWith..., reverseAllTransactions)

FLOWS NOT AFFECTED (still Financial Activity "Liability Transfer")
  - savings <-> loan (repayment, disbursement to savings, loan charges,
    guarantor recovery, Transfer Fee Charge For Loans job)
  - current account transfers
  - FD/RD closure "transfer to GL" (handleWithdrawal + manual JE against
    Liability Transfer in DepositAccountDomainServiceJpa)
  - branch-to-branch account transfer (already used Transfers In Suspense)
  - counter cash deposits / withdrawals

IF SOMETHING BREAKS - IS IT THIS CHANGE?
Likely this change if ALL of these hold:
  - the transaction is a transfer between two savings/FD/RD accounts (or
    its undo), and
  - the journal entry's clearing leg is on a product's Transfers In
    Suspense GL (e.g. 200901 LIABILITY TRANSFER SUSPENSE, or 200902
    SUNDRY CREDITORS for the FD products on the local tenant) instead of
    the Liability Transfer financial activity GL, or
  - a transfer's two legs land on DIFFERENT clearing GLs, or a reversal
    clears a different GL than the original (suspense balance left
    non-zero), or
  - a non-transfer posting inside the same request picked up the
    suspense GL (would mean the ThreadLocal leaked: look for a missing
    clearProductTransferSuspense()).
Not this change: savings <-> loan, current account, cash, or errors
"No Liability Transfer account is mapped" from the FD/RD close-to-GL path.

Quick checks:
  -- clearing legs of recent transfers
  SELECT j.transaction_id, j.type_enum, g.gl_code, g.name, j.amount
    FROM acc_gl_journal_entry j JOIN acc_gl_account g ON g.id = j.account_id
   WHERE j.savings_transaction_id IN (
         SELECT from_savings_transaction_id FROM m_account_transfer_transaction
         UNION SELECT to_savings_transaction_id FROM m_account_transfer_transaction)
   ORDER BY j.id DESC LIMIT 40;
  -- a clearing GL should net to zero over completed transfers
  SELECT SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END)
    FROM acc_gl_journal_entry WHERE account_id = <suspense gl id>;

To go back to the old behaviour: revert this commit (no schema or data
change is involved; transfers already posted to a product suspense GL
still reverse correctly only while this commit is in place).

VERIFIED
  - :fineract-provider:compileJava passes.
  - Code paths traced: all transferFunds callers, create(), undoTransactions;
    single-leg undo of a transfer is blocked by undoTransaction
    (allowAccountTransferModification=false), so reversals go through
    undoTransactions.
NOT YET VERIFIED
  - No transfer has been executed end-to-end after this change (RD auto
    debit, FD activation from savings, undo). spotlessJavaCheck not run
    meaningfully: it already fails repo-wide on CRLF line endings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:20:54 +05:30
niteshandClaude Opus 5.5 b8ebfb3aeb Merge feature/approval-live-badge-counts into dev-latest
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Live pending-count badges on the Approval Center tabs and the Approvals menu,
served over one SSE stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:09:10 +05:30
niteshandClaude Opus 5.5 3a320b9c09 feat(fixed-deposits): FD funding rules and no FD withdrawals from clerk/cashier requests
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
New FixedDepositFundingValidator, used on every path money can reach an FD:
- an FD is funded once, with exactly its principal: it must be active and not matured,
  hold no deposit yet, and the amount must equal its deposit amount;
- when funded from a Savings/Current account (clerk Bank/Fund Transfer), that account
  must be a Savings or Current account, active, not debit-frozen (sub-status BLOCK /
  BLOCK_DEBIT), out of lock-in, belong to the FD's client or be held jointly with them,
  and have enough available balance after holds, lien, clearing holds and minimum
  balance (plus overdraft for current accounts).

Applied in:
- FD deposit command (counter / FD screen / approved cashier deposit requests);
- cashier deposit request submit, so the counter sees the reason at once;
- clerk Disbursement / Collection / Fund Transfer / Bank Transfer, at submit and again
  at approval before the request is locked; one request funds FDs of one customer, an
  FD appears once, and an FD can never be a debit leg. After posting, each funded FD's
  maturity date and amount are recomputed (new
  DepositAccountWritePlatformService.recalculateFixedDepositMaturity), since the direct
  book credit does not do it;
- clerk withdrawal request submit refuses an FD: money leaves an FD only through its
  maturity or premature closure.

Previously a direct book debit in Collection / Bank Transfer could take money out of an
FD, bypassing the FD's own withdrawal refusal, and an FD could be credited any number of
times for any amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Opus 5.5 8ba0420398 feat(deposits): allow several FD/RD accounts on the same product for one client
A client may now open any number of Fixed Deposit and Recurring Deposit accounts on
the same product, even while an earlier one is still active. The one-active-account-
per-product check is removed from submitFDApplication and submitRDApplication only;
the same rule on plain Savings and Current accounts is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Sonnet 5.5 6cfa7ef3d1 feat(approvals): live Approvals badge counts over one SSE stream
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
One endpoint now answers how many requests wait on every Approval Center tab,
so the screen no longer makes a call per tab.

- GET /v1/approvals/counts: snapshot, numbers only (held 3s, fresh=true skips).
- GET /v1/approvals/counts/stream: Server-Sent Events. A `counts` event is
  written the moment the numbers change; one shared 1s check serves every open
  screen and callers counted alike share one query. Keep-alive ping every 15s,
  stream recycled every 5 minutes.
- All 26 tabs are counted by one statement of scalar sub-selects, each the same
  table/status/office scope/exclusions as the tab's own API. A tab the caller may
  not see is not asked (permissions mirrored, disbursement for managers only).
- Layered: thin API resource, ApprovalCountsReadPlatformService (+Impl),
  ApprovalCountsStreamService, ApprovalCounterRegistry (one line per tab).
- Adds jersey-media-sse (SseEventSink / Sse).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:49:14 +05:30
niteshandClaude Sonnet 5.5 6be32272c1 feat(cashier): maker-checker on cash transfer
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and
posted only when a different manager or head cashier approves it.

- POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same
  checks run again at approval, since the vault can change in between.
- Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a
  reason; the maker cannot approve their own request.
- An identical request from the same maker is refused while the first is still pending.
- The direct POST /cashtransfer is closed so the approval cannot be bypassed.
- Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:22 +05:30
niteshandClaude Sonnet 5.5 b47a34b5ef feat(vouchers): voucher correction for cash deposits posted to the wrong account
A cash deposit posted to the wrong savings account is corrected without ever undoing it behind anyone's back:
the voucher is unpassed (admin or clerk), a manager approves, the cashier enters the correct account, and a
manager approves again. Only the final approval moves money: the deposit is reversed on the wrong account, the
same cash is deposited into the right one under a new voucher, and the cashier's denominations follow it.

- VoucherCorrectionApiResource: eligible list, unpass, approve/reject unpass, account lookup, correction,
  approve/reject correction, close request, voided transactions.
- The wrong account's balance is checked when the cashier enters the correction and again at approval.
- A cashier whose customer has already used the money closes the request and deposits separately.
- Roles are enforced on the server: unpass for admin/clerk, correction and close for a cashier, approvals for
  a manager or head cashier other than the maker.
- Migration 0601 adds m_voucher_correction; 0602 drops the status columns an earlier draft put on m_voucher,
  since the correction status is read from m_voucher_correction alone.
- Voucher registers (register, My Vouchers, day book, voucher detail) report each voucher's correction status
  and leave a revoked voucher out of their totals, so its cash is not counted twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:21 +05:30
niteshandClaude Sonnet 5.5 04d1eb20f0 feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same
maker-checker path as a savings withdrawal:
- the request carries gl_account_id, contra_gl_id, office_id and currency_code
  (new nullable columns, changeset 0600) instead of a savings account;
- at submit it is validated: the paying GL must be a mapped cash vault GL and the
  slip's own GL must not be a cash GL;
- at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry;
- the cashier pays it against its token, and the denomination and voucher lines are
  recorded under the journal entry's id. KYC debit checks are skipped for GL requests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:01 +05:30
85 changed files with 13336 additions and 67 deletions
+352
View File
@@ -0,0 +1,352 @@
# 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.
@@ -79,6 +79,8 @@ public enum JobName {
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
I4C_DISPATCH("I4C Dispatch"), //
I4C_HOLD_REVIEW("I4C Hold Review"), //
; //
private final String name;
+2
View File
@@ -81,6 +81,8 @@ dependencies {
'org.apache.avro:avro',
'jakarta.ws.rs:jakarta.ws.rs-api',
'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-context-support',
@@ -1570,9 +1570,76 @@ public class AccountingProcessorHelper {
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,
final Long paymentTypeId) {
GLAccount glAccount;
if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) {
return TRANSFER_CLEARING_ACCOUNT.get();
}
if (isOrganizationAccount(accountMappingTypeId)) {
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
@@ -124,6 +124,8 @@ public class SecurityConfig {
private ProgressiveLoanModelCheckerFilter progressiveLoanModelCheckerFilter;
@Autowired
private PlatformUserDetailsChecker platformUserDetailsChecker;
@org.springframework.beans.factory.annotation.Value("${fineract.i4c.tenant-identifier:}")
private String i4cTenantIdentifier;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@@ -164,6 +166,16 @@ public class SecurityConfig {
// packages here without a Fineract Bearer token; security is enforced by the
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/npci/**")).permitAll()
// I4C (NCRP-CFCFRMS) server-to-server endpoints — I4C-MHA calls these per its Banks API
// Integration Specification 2.0 with its own token flow (POST username/password ->
// access_token, then Bearer), not a Fineract login. I4cInboundAuthService inside the
// handlers checks the token, its expiry and the caller IP allowlist. The officer screens
// under /i4c/admin/** are NOT listed here and stay behind the ordinary login.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/i4c/auth/token")).permitAll()
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/i4c/complaints")).permitAll()
// Local stand-in for I4C, off unless fineract.i4c.simulator.enabled=true (the resource
// refuses every call otherwise), so UAT can run end to end without the C-DOT VPN.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/i4c-sim/**")).permitAll()
// Client account summary, third-party callers — a partner integration calls this
// with an X-Api-Key header instead of a Fineract username/password. The key itself
// is issued by inserting directly into m_api_client in the tenant-store database
@@ -550,6 +562,7 @@ public class SecurityConfig {
userNotificationService, basicAuthTenantDetailsService, businessDateReadPlatformService);
filter.setRequestMatcher(API_MATCHER.matcher("/api/**"));
filter.setI4cDefaultTenantIdentifier(i4cTenantIdentifier);
return filter;
}
@@ -144,14 +144,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
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 SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
// As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped.
final SavingsAccountTransaction withdrawal;
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);
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
@@ -277,14 +285,27 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
if (accountTransfer.getToLoanTransaction() != null) {
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) {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(),
true);
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true);
try {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(),
accountTransfer.getFromTransaction().getId(), true);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
}
if (accountTransfer.getToSavingsTransaction() != null) {
this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
accountTransfer.getToSavingsTransaction().getId(), true);
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false);
try {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
accountTransfer.getToSavingsTransaction().getId(), true);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
}
accountTransfer.reverse();
this.accountTransferRepository.save(accountTransfer);
@@ -407,13 +428,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
transactionDate = transactionDate.plusDays(1);
}
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill);
// Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving
// product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping.
final SavingsAccountTransaction withdrawal;
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);
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate,
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer,
isRegularTransaction, backdatedTxnsAllowedTill);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
@@ -0,0 +1,67 @@
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;
}
}
}
@@ -0,0 +1,14 @@
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) {}
@@ -0,0 +1,27 @@
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);
}
}
@@ -0,0 +1,22 @@
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) {}
@@ -0,0 +1,122 @@
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'"));
}
@@ -0,0 +1,24 @@
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);
}
@@ -0,0 +1,114 @@
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"));
}
}
@@ -0,0 +1,145 @@
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.
}
}
}
@@ -56,6 +56,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -98,6 +99,7 @@ public class CashierDepositRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
@@ -155,6 +157,11 @@ public class CashierDepositRequestApiResource {
throw apiError(400, "Amount must be greater than zero");
}
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
// An FD is funded once, with exactly its principal. Told here at the counter rather than when the
// manager approves; the FD deposit command checks the same again at posting.
if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) {
fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount);
}
validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue);
@@ -20,6 +20,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -36,6 +37,7 @@ public class CashReportApiResource {
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper;
private final VaultPositionService vaultPositionService;
private final VoucherService voucherService;
private final Gson gson = new Gson();
/**
@@ -329,7 +331,18 @@ public class CashReportApiResource {
BigDecimal priorCreditTotal = BigDecimal.ZERO;
BigDecimal priorDebitTotal = BigDecimal.ZERO;
// Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on
// the voucher that replaced it, so it must not be counted in the day's totals a second time.
final Map<String, Map<String, Object>> marks = voucherService.voucherMarks(
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
for (final Map<String, Object> row : rows) {
final Map<String, Object> mark = marks.get((String) row.get("voucherNo"));
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
final boolean revoked = "REVOKED".equals(row.get("voucherStatus"));
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue;
}
@@ -350,7 +363,7 @@ public class CashReportApiResource {
// was already earned, and the day it belongs to has been counted and signed off. Adding
// it to this day's totals would make them disagree with the ledger for either date.
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
@@ -57,9 +57,23 @@ public class CashTransferApiResource {
private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson();
/**
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
* {@link #postTransfer}.
*/
@POST
public String create(String body) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
"A cash transfer is raised as a request and posted when a manager approves it.");
}
/**
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
* when it is approved, since the vault can change in between.
*/
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
@@ -102,6 +116,9 @@ public class CashTransferApiResource {
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
if (dryRun) {
return "{}";
}
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
@@ -112,15 +129,9 @@ public class CashTransferApiResource {
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
}
} catch (Exception e) {
// resolving the current user must never block recording the transfer
}
// Recorded against the person who raised the request, not the one who approved it.
header.setPerformedBy(performedBy);
header.setPerformedByUsername(performedByUsername);
// Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
@@ -159,6 +170,171 @@ public class CashTransferApiResource {
return gson.toJson(result);
}
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
@POST
@Path("requests")
public String submitRequest(final String body) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
postTransfer(body, user.getId(), user.getUsername(), true);
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
// a second click would otherwise release the same cash twice once both were approved.
final Integer identical = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
REQUEST_PENDING, user.getId(), body);
if (identical != null && identical > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
"An identical cash transfer request from you is already waiting for approval.");
}
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final JsonArray denominations = json.getAsJsonArray("denominations");
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_cash_transfer_request
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
status, maker_id, maker_name, maker_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_PENDING);
return gson.toJson(result);
}
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
@GET
@Path("requests")
public String listRequests(@QueryParam("status") final String status,
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
final List<Object> params = new java.util.ArrayList<>();
if (status != null && !status.isBlank()) {
final String[] statuses = status.split(",");
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
for (final String s : statuses) {
params.add(s.trim().toUpperCase());
}
}
if (mine) {
sql.append(" AND maker_id = ?");
params.add(user.getId());
}
sql.append(" ORDER BY id DESC LIMIT 200");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
for (final Map<String, Object> row : rows) {
// The notes asked for, so the approver sees what they are releasing without opening anything.
final Object requestBody = row.remove("request_body");
row.put("denominations", requestBody == null ? new JsonArray()
: JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations"));
}
return gson.toJson(rows);
}
/** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */
@POST
@Path("requests/{id}/approve")
public String approveRequest(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "approved");
final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null;
if (makerId != null && makerId.equals(user.getId())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user",
"Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager.");
}
final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false);
final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject();
final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null;
final int updated = jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ?
WHERE id = ? AND status = ?
""", REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
transferId, id, REQUEST_PENDING);
if (updated == 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned",
"This request was already actioned by another manager.");
}
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_APPROVED);
if (transferId != null) {
result.addProperty("cashTransferId", transferId);
}
if (postedJson.has("glWarning")) {
result.add("glWarning", postedJson.get("glWarning"));
}
return gson.toJson(result);
}
@POST
@Path("requests/{id}/reject")
public String rejectRequest(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "rejected");
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
final String reason = textOrNull(json, "reason");
if (reason == null || reason.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject.");
}
jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
WHERE id = ? AND status = ?
""", REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
reason.trim(), id, REQUEST_PENDING);
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_REJECTED);
return gson.toJson(result);
}
private static final String REQUEST_PENDING = "PENDING";
private static final String REQUEST_APPROVED = "APPROVED";
private static final String REQUEST_REJECTED = "REJECTED";
private Map<String, Object> loadRequest(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found.");
}
return rows.get(0);
}
private void requirePending(final Map<String, Object> request, final String action) {
if (!REQUEST_PENDING.equals(request.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status",
"Only PENDING requests can be " + action + ". Current status: " + request.get("status"));
}
}
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
private void requireChecker(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker",
"Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer.");
}
}
private static String textOrNull(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
@GET
public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -0,0 +1,331 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.UriInfo;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cAdminReadService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cComplaintProcessor;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cHoldService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cIntimationService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cListQuery;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cOutboxService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cProcessingRunner;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cSystemContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Officer screens for the I4C integration, behind the ordinary CBS login.
*
* READ_I4C to see anything; REQUESTACTION_I4CHOLD to ask for a release or a debit freeze/unfreeze;
* APPROVEACTION_I4CHOLD to approve or reject one (never your own); INTIMATE_I4CHOLD to record that the customer was
* told; RETRY_I4C to resend a failed response or reprocess a complaint.
*/
@Path("/v1/i4c/admin")
@Component
@Tag(name = "I4C CFCFRMS (officer)", description = "Monitoring and actions on I4C complaints, holds and responses.")
@RequiredArgsConstructor
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class I4cAdminApiResource {
private static final String RESOURCE = "I4C";
private final PlatformSecurityContext context;
private final I4cAdminReadService readService;
private final I4cHoldService holdService;
private final I4cIntimationService intimationService;
private final I4cOutboxService outboxService;
private final I4cProcessingRunner runner;
private final I4cSystemContext systemContext;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Path("dashboard")
@Operation(summary = "Integration health, volumes, holds and the API performance figures for periodic review")
public String dashboard() {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.dashboard());
}
@GET
@Path("requests")
@Operation(summary = "Complaints, one page at a time: status, search, subCategory, layer, response, fromDate, toDate, offset, limit")
public String requests(@Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.requests(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@GET
@Path("requests/{id}")
public String request(@PathParam("id") final Long id) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.request(id));
}
@POST
@Path("requests/{id}/reprocess")
@Operation(summary = "Process a complaint now that is waiting for a retry (idempotent: holds are never placed twice)")
public String reprocess(@PathParam("id") final Long id) {
context.authenticatedUser().validateHasPermissionTo("RETRY_I4C");
final int updated = jdbcTemplate.update("UPDATE m_i4c_request SET status = ? WHERE id = ? AND status IN (?, ?)",
I4cComplaintProcessor.RETRY, id, I4cComplaintProcessor.RETRY, I4cComplaintProcessor.RECEIVED);
if (updated == 0) {
throw new PlatformDataIntegrityException("error.msg.i4c.request.not.retryable",
"Only a complaint that is RECEIVED or waiting for RETRY can be reprocessed; to resend a reply use the outbox retry.");
}
systemContext.runAsSystem(() -> runner.processAndSend(id));
return gson.toJson(readService.request(id));
}
@GET
@Path("holds")
@Operation(summary = "Holds register: view, search, accountType, intimation, frozen, layer, fromDate, toDate, offset, limit")
public String holds(@Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.holds(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@GET
@Path("holds/{id}")
public String hold(@PathParam("id") final Long id) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.hold(id));
}
@POST
@Path("holds/{id}/actions")
@Operation(summary = "Maker: ask to RELEASE the hold, or to FREEZE / UNFREEZE debits on the account")
public String requestAction(@PathParam("id") final Long id, final String body) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("REQUESTACTION_I4CHOLD");
final JsonObject json = parse(body);
final String orderDate = text(json, "orderDate");
final String amount = text(json, "amount");
final String action = text(json, "action");
if ("RESTORE".equalsIgnoreCase(action == null ? "" : action.trim())) {
// A restoration rests on its MRM record and its papers; it is raised from there, not as a bare request.
throw new PlatformDataIntegrityException("error.msg.i4c.restore.from.mrm",
"Raise a restoration from its MRM record (Restorations page, bank action Compliance).");
}
final java.math.BigDecimal releaseAmount;
try {
releaseAmount = amount == null || amount.isBlank() ? null : new java.math.BigDecimal(amount.trim());
} catch (final NumberFormatException e) {
throw new PlatformDataIntegrityException("error.msg.i4c.number.invalid", "amount must be a number.", e);
}
final Long requestId = holdService.requestAction(id, action, text(json, "reasonType"), text(json, "orderReference"),
orderDate == null || orderDate.isBlank() ? null : parseDate(orderDate), text(json, "authority"), text(json, "remarks"),
releaseAmount, null, null, user);
return result("resourceId", requestId);
}
@GET
@Path("hold-actions")
@Operation(summary = "Release / freeze requests: status, action, reasonType, search, fromDate, toDate, offset, limit")
public String holdActions(@Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.holdActions(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@POST
@Path("hold-actions/{id}/approve")
@Operation(summary = "Checker: approve and carry out a release / freeze / unfreeze request raised by someone else")
public String approve(@PathParam("id") final Long id) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("APPROVEACTION_I4CHOLD");
holdService.approveAction(id, user);
return result("resourceId", id);
}
@POST
@Path("hold-actions/{id}/reject")
public String reject(@PathParam("id") final Long id, final String body) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("APPROVEACTION_I4CHOLD");
holdService.rejectAction(id, text(parse(body), "reason"), user);
return result("resourceId", id);
}
@POST
@Path("holds/{id}/intimation")
@Operation(summary = "Record that the account holder was told about the hold")
public String intimation(@PathParam("id") final Long id, final String body) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("INTIMATE_I4CHOLD");
final JsonObject json = parse(body);
holdService.markIntimated(id, text(json, "mode"), text(json, "reference"), user);
intimationService.logManual(id, text(json, "mode"), text(json, "reference"), user.getDisplayName());
return result("resourceId", id);
}
@POST
@Path("holds/{id}/intimation/send")
@Operation(summary = "Send the hold notice to the customer now, on the switched-on automatic channels")
public String sendIntimation(@PathParam("id") final Long id) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("INTIMATE_I4CHOLD");
return gson.toJson(resultMap(intimationService.sendNow(id, user.getDisplayName())));
}
@GET
@Path("intimation-channels")
@Operation(summary = "Automatic customer-intimation channels, their order and message text")
public String intimationChannels() {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("channels", intimationService.channels());
out.put("placeholders", I4cIntimationService.PLACEHOLDERS);
out.put("manualChannels", java.util.List.of("PHONE", "LETTER", "IN_PERSON", "SMS", "EMAIL", "WHATSAPP"));
return gson.toJson(out);
}
@PUT
@Path("intimation-channels/{channel}")
@Operation(summary = "Switch an automatic intimation channel on or off, reorder it, or change its message")
public String updateIntimationChannel(@PathParam("channel") final String channel, final String body) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("CONFIGURE_I4C");
final JsonObject json = parse(body);
intimationService.updateChannel(channel, json.has("enabled") && !json.get("enabled").isJsonNull() ? json.get("enabled").getAsBoolean() : null,
json.has("priority") && !json.get("priority").isJsonNull() ? json.get("priority").getAsInt() : null, text(json, "subject"),
text(json, "body"), user.getDisplayName());
return gson.toJson(intimationService.channels());
}
@POST
@Path("intimation-channels/{channel}/test")
@Operation(summary = "Send the channel's message, filled with sample values, to a test address (EMAIL)")
public String testIntimationChannel(@PathParam("channel") final String channel, final String body) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo("CONFIGURE_I4C");
return gson.toJson(resultMap(intimationService.sendTest(channel, text(parse(body), "to"))));
}
private static Map<String, Object> resultMap(final I4cIntimationService.Result r) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put("outcome", r.outcome().name());
out.put("channel", r.channel());
out.put("destination", r.destination());
out.put("message", r.message());
return out;
}
@GET
@Path("outbox")
@Operation(summary = "Messages to I4C: status, type (ACK / RESPONSE), search, fromDate, toDate, offset, limit")
public String outbox(@Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.outbox(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@POST
@Path("outbox/{id}/retry")
public String retryOutbox(@PathParam("id") final Long id) {
context.authenticatedUser().validateHasPermissionTo("RETRY_I4C");
outboxService.retry(id);
final boolean sent = outboxService.send(id);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", id);
out.put("sent", sent);
return gson.toJson(out);
}
@GET
@Path("api-log")
@Operation(summary = "API log: direction, failures, httpStatus, responseCode, search, fromDate, toDate, offset, limit")
public String apiLog(@Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.apiLog(I4cListQuery.of(uriInfo.getQueryParameters(), 20)));
}
@GET
@Path("api-log/{id}")
public String apiLogEntry(@PathParam("id") final Long id) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.apiLogEntry(id));
}
@GET
@Path("bank-master")
@Operation(summary = "I4C bank code master: search, type, ifsc (KNOWN / UNKNOWN), offset, limit")
public String bankMaster(@Context final UriInfo uriInfo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.bankMaster(I4cListQuery.of(uriInfo.getQueryParameters(), 50)));
}
@GET
@Path("configuration")
public String configuration() {
context.authenticatedUser().validateHasReadPermission(RESOURCE);
return gson.toJson(readService.configuration());
}
private String result(final String key, final Object value) {
final Map<String, Object> out = new LinkedHashMap<>();
out.put(key, value);
return gson.toJson(out);
}
private static LocalDate parseDate(final String value) {
try {
return LocalDate.parse(value.trim());
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.i4c.order.date.invalid", "Order date must be yyyy-MM-dd.", e);
}
}
private static JsonObject parse(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (final RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.i4c.body.invalid", "Request body must be a JSON object.", e);
}
}
private static String text(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
}
@@ -0,0 +1,496 @@
/**
* 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);
}
}
}
@@ -0,0 +1,308 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.api;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.apache.fineract.portfolio.cfcfrms.i4c.crypto.I4cCryptoJsAes;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cAcks;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cComplaintParser;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cApiLogService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cInboundAuthService;
import org.apache.fineract.portfolio.cfcfrms.i4c.service.I4cRequestService;
import org.springframework.stereotype.Component;
/**
* The two endpoints I4C calls on the bank (I4C-MHA Banks API Integration Specification 2.0):
* <ul>
* <li>{@code POST /v1/i4c/auth/token} - section 2, "Authenticate from I4C-MHA to BANKS": username/password in, an
* {@code access_token} out, or 401 {@code {"message":"You are not authorized to access"}}.</li>
* <li>{@code POST /v1/i4c/complaints} - section 4, the complaint push, answered with the section 5 ack: response code
* 00 and the bank's {@code job_id}, or 11 "Structure validation failed". The bank's findings follow later as a separate
* call to I4C (section 6).</li>
* </ul>
* Both are outside the Fineract login (see SecurityConfig) and authenticate the caller here: token, expiry and source
* IP. Every call, accepted or refused, is written to the I4C API log.
*/
@Slf4j
@Path("/v1/i4c")
@Component
@Tag(name = "I4C CFCFRMS (bank side, called by I4C)", description = "Endpoints the I4C-MHA NCRP-CFCFRMS platform calls on the bank.")
@RequiredArgsConstructor
public class I4cInboundApiResource {
private static final String NOT_AUTHORIZED = "You are not authorized to access";
private static final int MAX_BODY_BYTES = 1_000_000;
/** Requests per source IP per minute; far above any real I4C rate, low enough to blunt a flood. */
private static final int RATE_LIMIT_PER_MINUTE = 600;
private final I4cProperties properties;
private final I4cInboundAuthService authService;
private final I4cRequestService requestService;
private final I4cApiLogService apiLog;
private final Map<String, long[]> rateWindows = new ConcurrentHashMap<>();
@POST
@Path("auth/token")
@Consumes({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_FORM_URLENCODED, MediaType.TEXT_PLAIN })
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "I4C obtains an access token (specification section 2): JSON username/password, client_id/client_secret,"
+ " a form body, or an Authorization: Basic header")
public Response token(final String body, @HeaderParam("Authorization") final String authorization,
@Context final HttpServletRequest http) {
final long start = System.nanoTime();
final String ip = authService.clientIp(http);
final String endpoint = "POST /v1/i4c/auth/token";
final Response refusal = gate(ip, body);
if (refusal != null) {
apiLog.record(I4cApiLogService.IN, endpoint, refusal.getStatus(), null, null, elapsed(start), ip, body, null,
"refused at gate");
return refusal;
}
// Credentials, in the order the specification and OAuth 2.0 allow: an Authorization: Basic header
// (client_id:client_secret), then the body as JSON (username/password or client_id/client_secret), then as a form.
String[] credentials = I4cInboundAuthService.basicCredentials(authorization);
if (credentials == null) {
credentials = credentialsFromBody(body);
}
final Optional<I4cInboundAuthService.IssuedToken> token = credentials == null ? Optional.empty()
: authService.issueToken(credentials[0], credentials[1], ip);
if (token.isEmpty()) {
final String out = message(NOT_AUTHORIZED);
apiLog.record(I4cApiLogService.IN, endpoint, 401, I4cCodes.UNAUTHORIZED, null, elapsed(start), ip, body, out,
"invalid credentials");
return json(401, out);
}
final JsonObject out = new JsonObject();
out.addProperty("access_token", token.get().accessToken());
out.addProperty("token_type", "Bearer");
out.addProperty("expires_in", token.get().expiresInSeconds());
final String outText = wrap(out.toString());
apiLog.record(I4cApiLogService.IN, endpoint, 200, I4cCodes.ACK_SUCCESS, null, elapsed(start), ip, body, out.toString(), null);
return json(200, outText);
}
@POST
@Path("complaints")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "I4C pushes a cyber-fraud complaint (specification sections 4 and 5); authenticated by Bearer token, Basic"
+ " header, or username/password inside the message")
public Response complaint(final String body, @HeaderParam("Authorization") final String authorization,
@Context final HttpServletRequest http) {
final long start = System.nanoTime();
final String ip = authService.clientIp(http);
final String endpoint = "POST /v1/i4c/complaints";
final Response refusal = gate(ip, body);
if (refusal != null) {
apiLog.record(I4cApiLogService.IN, endpoint, refusal.getStatus(), null, null, elapsed(start), ip, body, null,
"refused at gate");
return refusal;
}
// Read first: the message may carry its own credentials (section 2). An unreadable body is reported only to a
// caller that has authenticated some other way.
JsonObject json = null;
RuntimeException bodyError = null;
try {
json = unwrap(body);
} catch (final RuntimeException e) {
bodyError = e;
}
final I4cProperties.AuthMode mode = authService.authenticatePush(authorization, json);
if (mode == null) {
final String out = message(NOT_AUTHORIZED);
apiLog.record(I4cApiLogService.IN, endpoint, 401, I4cCodes.UNAUTHORIZED, null, elapsed(start), ip, body, out,
"no valid token, Basic credentials or message credentials");
return json(401, out);
}
if (bodyError != null) {
final boolean crypto = bodyError instanceof I4cCryptoJsAes.I4cCryptoException;
final String code = crypto ? I4cCodes.DECRYPTION_ERROR : I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED;
final String out = I4cAcks.ack(code, null, null);
apiLog.record(I4cApiLogService.IN, endpoint, 400, code, null, elapsed(start), ip, crypto ? null : body, out,
crypto ? bodyError.getMessage() : "body is not a JSON object");
return json(400, wrap(out));
}
final I4cComplaintParser.Result parsed = I4cComplaintParser.parse(json, DateUtils.getBusinessLocalDate(),
properties.getAck().getMaxFutureDays());
if (!parsed.isValid()) {
final String ackNo = text(json, "acknowledgement_no");
final String code = properties.getAck().getCodeStyle() == I4cProperties.AckCodeStyle.SECTION7 ? parsed.section7Code()
: I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED;
final String out = I4cAcks.ack(code, ackNo, "BANKS-" + UUID.randomUUID());
apiLog.record(I4cApiLogService.IN, endpoint, 400, code, ackNo, elapsed(start), ip, json.toString(), out,
String.join("; ", parsed.errors()));
return json(400, wrap(out));
}
final String ackNo = parsed.complaint().acknowledgementNo();
try {
final I4cRequestService.Accepted accepted = requestService.accept(parsed.complaint(), json.toString(), ip);
final boolean refuseDuplicate = accepted.duplicate()
&& properties.getAck().getDuplicateMode() == I4cProperties.DuplicateMode.CODE_50003;
final String code = refuseDuplicate ? I4cCodes.DUPLICATE_TRANSACTION : I4cCodes.ACK_SUCCESS;
final String out = I4cAcks.ack(code, ackNo, accepted.jobId());
final int status = refuseDuplicate ? 409 : 200;
apiLog.record(I4cApiLogService.IN, endpoint, status, code, ackNo, elapsed(start), ip, json.toString(), out,
(accepted.duplicate() ? "repeat push, original job_id returned; " : "") + "auth " + mode);
return json(status, wrap(out));
} catch (final RuntimeException e) {
log.error("I4C complaint {} could not be stored", ackNo, e);
final String out = I4cAcks.ack(I4cCodes.SERVICE_UNAVAILABLE, ackNo, null);
apiLog.record(I4cApiLogService.IN, endpoint, 503, I4cCodes.SERVICE_UNAVAILABLE, ackNo, elapsed(start), ip, json.toString(), out,
e.getClass().getSimpleName() + ": " + e.getMessage());
return json(503, wrap(out));
}
}
/** username/password or client_id/client_secret from a JSON (possibly encrypted-envelope) or form body. */
private String[] credentialsFromBody(final String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
if (body.trim().startsWith("{")) {
return I4cInboundAuthService.credentialsIn(unwrap(body));
}
final JsonObject form = new JsonObject();
for (final String pair : body.split("&")) {
final int eq = pair.indexOf('=');
if (eq > 0) {
form.addProperty(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8),
URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
}
}
return I4cInboundAuthService.credentialsIn(form);
} catch (final RuntimeException e) {
log.debug("I4C token body unreadable: {}", e.getMessage());
return null;
}
}
/**
* Integration switched off, caller not on the IP allowlist, body too large, or too many calls: refused before any
* work.
*/
private Response gate(final String ip, final String body) {
if (!properties.isEnabled()) {
return json(503, I4cAcks.ack(I4cCodes.MAINTENANCE, null, null));
}
if (!authService.isIpAllowed(ip)) {
log.warn("I4C call refused from non-allowlisted IP {}", ip);
return json(403, message(NOT_AUTHORIZED));
}
if (body != null && body.getBytes(StandardCharsets.UTF_8).length > MAX_BODY_BYTES) {
return json(413, I4cAcks.ack(I4cCodes.REQUEST_TOO_LARGE, null, null));
}
if (!withinRateLimit(ip)) {
return json(429, I4cAcks.ack(I4cCodes.RATE_LIMIT, null, null));
}
return null;
}
private boolean withinRateLimit(final String ip) {
final long minute = System.currentTimeMillis() / 60_000L;
final long[] window = rateWindows.compute(ip == null ? "-" : ip, (k, w) -> {
if (w == null || w[0] != minute) {
return new long[] { minute, 1 };
}
w[1]++;
return w;
});
if (rateWindows.size() > 10_000) {
rateWindows.entrySet().removeIf(e -> e.getValue()[0] != minute);
}
return window[1] <= RATE_LIMIT_PER_MINUTE;
}
private JsonObject unwrap(final String body) {
final JsonElement element = JsonParser.parseString(body == null ? "" : body);
if (!element.isJsonObject()) {
throw new IllegalArgumentException("not a JSON object");
}
final JsonObject object = element.getAsJsonObject();
final I4cProperties.Crypto crypto = properties.getCrypto();
if (crypto.getMode() == I4cProperties.CryptoMode.ENVELOPE && object.has("data") && object.get("data").isJsonPrimitive()
&& I4cCryptoJsAes.looksEncrypted(object.get("data").getAsString())) {
if (!crypto.hasPassphrase()) {
throw new I4cCryptoJsAes.I4cCryptoException("fineract.i4c.crypto.passphrase is not set", null);
}
final JsonElement inner = JsonParser
.parseString(I4cCryptoJsAes.decrypt(object.get("data").getAsString(), crypto.getPassphrase()));
if (!inner.isJsonObject()) {
throw new IllegalArgumentException("decrypted body is not a JSON object");
}
return inner.getAsJsonObject();
}
return object;
}
private String wrap(final String json) {
final I4cProperties.Crypto crypto = properties.getCrypto();
if (crypto.getMode() == I4cProperties.CryptoMode.ENVELOPE && crypto.hasPassphrase()) {
final JsonObject envelope = new JsonObject();
envelope.addProperty("data", I4cCryptoJsAes.encrypt(json, crypto.getPassphrase()));
return envelope.toString();
}
return json;
}
private static String message(final String text) {
final JsonObject out = new JsonObject();
out.addProperty("message", text);
return out.toString();
}
private static Response json(final int status, final String body) {
return Response.status(status).type(MediaType.APPLICATION_JSON).entity(body).build();
}
private static String text(final JsonObject json, final String key) {
return json.has(key) && json.get(key).isJsonPrimitive() ? json.get(key).getAsString() : null;
}
private static long elapsed(final long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000L;
}
}
@@ -0,0 +1,180 @@
/**
* 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;
}
}
@@ -0,0 +1,133 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.crypto;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* AES-256 in the format the I4C specification's samples are written in.
*
* The specification's encrypted username/password samples begin {@code U2FsdGVkX1}, which is Base64 for the 8 bytes
* {@code Salted__}: the format OpenSSL's {@code enc -aes-256-cbc -md md5} and CryptoJS's {@code AES.encrypt(text,
* passphrase)} both produce. The layout is {@code "Salted__" + 8-byte salt + ciphertext}, Base64 encoded; the 32-byte
* key and 16-byte IV come from the passphrase and salt through OpenSSL's EVP_BytesToKey with MD5 and one iteration; the
* cipher is AES/CBC with PKCS#7 padding (PKCS5Padding in the JCE).
*
* MD5 and a single iteration are weak key derivation, but they are what the counterparty uses; the transport is TLS
* over a whitelisted connection, and this layer only has to match it.
*/
public final class I4cCryptoJsAes {
private static final byte[] SALTED_MAGIC = "Salted__".getBytes(StandardCharsets.US_ASCII);
private static final int SALT_LENGTH = 8;
private static final int KEY_LENGTH = 32;
private static final int IV_LENGTH = 16;
private static final SecureRandom RANDOM = new SecureRandom();
private I4cCryptoJsAes() {}
/** True for text that is in the {@code Salted__} format (Base64 starting {@code U2FsdGVkX1}). */
public static boolean looksEncrypted(final String value) {
return value != null && value.startsWith("U2FsdGVkX1");
}
public static String encrypt(final String plainText, final String passphrase) {
final byte[] salt = new byte[SALT_LENGTH];
RANDOM.nextBytes(salt);
return encrypt(plainText, passphrase, salt);
}
/**
* Encrypts with the given salt; only tests need a fixed salt, everything else uses
* {@link #encrypt(String, String)}.
*/
static String encrypt(final String plainText, final String passphrase, final byte[] salt) {
try {
final byte[][] keyAndIv = evpBytesToKey(passphrase.getBytes(StandardCharsets.UTF_8), salt);
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyAndIv[0], "AES"), new IvParameterSpec(keyAndIv[1]));
final byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
final byte[] out = new byte[SALTED_MAGIC.length + SALT_LENGTH + cipherText.length];
System.arraycopy(SALTED_MAGIC, 0, out, 0, SALTED_MAGIC.length);
System.arraycopy(salt, 0, out, SALTED_MAGIC.length, SALT_LENGTH);
System.arraycopy(cipherText, 0, out, SALTED_MAGIC.length + SALT_LENGTH, cipherText.length);
return Base64.getEncoder().encodeToString(out);
} catch (final GeneralSecurityException e) {
throw new I4cCryptoException("Encryption failed", e);
}
}
public static String decrypt(final String base64CipherText, final String passphrase) {
final byte[] all;
try {
all = Base64.getDecoder().decode(base64CipherText.trim());
} catch (final IllegalArgumentException e) {
throw new I4cCryptoException("Encrypted value is not valid Base64", e);
}
if (all.length <= SALTED_MAGIC.length + SALT_LENGTH
|| !Arrays.equals(Arrays.copyOfRange(all, 0, SALTED_MAGIC.length), SALTED_MAGIC)) {
throw new I4cCryptoException("Encrypted value is not in the Salted__ format", null);
}
final byte[] salt = Arrays.copyOfRange(all, SALTED_MAGIC.length, SALTED_MAGIC.length + SALT_LENGTH);
final byte[] cipherText = Arrays.copyOfRange(all, SALTED_MAGIC.length + SALT_LENGTH, all.length);
try {
final byte[][] keyAndIv = evpBytesToKey(passphrase.getBytes(StandardCharsets.UTF_8), salt);
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyAndIv[0], "AES"), new IvParameterSpec(keyAndIv[1]));
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
} catch (final GeneralSecurityException e) {
// A wrong passphrase almost always surfaces here as a padding failure.
throw new I4cCryptoException("Decryption failed (wrong passphrase or corrupted value)", e);
}
}
/** OpenSSL EVP_BytesToKey with MD5, one iteration: D_i = MD5(D_{i-1} || password || salt), concatenated. */
private static byte[][] evpBytesToKey(final byte[] password, final byte[] salt) throws GeneralSecurityException {
final MessageDigest md5 = MessageDigest.getInstance("MD5");
final byte[] derived = new byte[KEY_LENGTH + IV_LENGTH];
byte[] previous = new byte[0];
int filled = 0;
while (filled < derived.length) {
md5.reset();
md5.update(previous);
md5.update(password);
md5.update(salt);
previous = md5.digest();
final int n = Math.min(previous.length, derived.length - filled);
System.arraycopy(previous, 0, derived, filled, n);
filled += n;
}
return new byte[][] { Arrays.copyOfRange(derived, 0, KEY_LENGTH), Arrays.copyOfRange(derived, KEY_LENGTH, derived.length) };
}
public static final class I4cCryptoException extends RuntimeException {
public I4cCryptoException(final String message, final Throwable cause) {
super(message, cause);
}
}
}
@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.data;
import com.google.gson.JsonObject;
import java.util.Map;
/** The section 5 ack message, and the wording of each code the bank answers a push with. */
public final class I4cAcks {
private I4cAcks() {}
/** Section 7's response messages for the codes an ack can carry. */
private static final Map<String, String> MESSAGES = Map.ofEntries(Map.entry(I4cCodes.ACK_SUCCESS, "Success"),
Map.entry(I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED, "Structure validation failed"),
Map.entry(I4cCodes.INVALID_ACCOUNT_NUMBER, "Invalid Account Number"), Map.entry(I4cCodes.INVALID_AMOUNT, "Invalid Transaction Amount"),
Map.entry(I4cCodes.INVALID_DATE, "Invalid Date"), Map.entry(I4cCodes.DUPLICATE_TRANSACTION, "Duplicate Transaction"),
Map.entry(I4cCodes.SERVICE_UNAVAILABLE, "Service Unavailable"), Map.entry(I4cCodes.DECRYPTION_ERROR, "Encryption/Decryption Error"),
Map.entry(I4cCodes.MAINTENANCE, "Service Temporarily Unavailable"), Map.entry(I4cCodes.REQUEST_TOO_LARGE, "Request Size Too Large"),
Map.entry(I4cCodes.RATE_LIMIT, "Rate Limit Exceeded"));
public static String messageFor(final String code) {
return MESSAGES.getOrDefault(code, "Unmapped Exception");
}
/** {@code {"meta":{"response_code","response_message"},"data":{"acknowledgement_no","job_id"}}}. */
public static String ack(final String code, final String acknowledgementNo, final String jobId) {
final JsonObject meta = new JsonObject();
meta.addProperty("response_code", code);
meta.addProperty("response_message", messageFor(code));
final JsonObject data = new JsonObject();
if (acknowledgementNo != null) {
data.addProperty("acknowledgement_no", acknowledgementNo);
}
if (jobId != null) {
data.addProperty("job_id", jobId);
}
final JsonObject out = new JsonObject();
out.add("meta", meta);
out.add("data", data);
return out.toString();
}
}
@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.data;
/** Codes fixed by the I4C-MHA Banks API Integration Specification 2.0. */
public final class I4cCodes {
private I4cCodes() {}
/** Section 5: ack codes. */
public static final String ACK_SUCCESS = "00";
public static final String ACK_STRUCTURE_VALIDATION_FAILED = "11";
/** Section 6 / 7: per-transaction status_code in the response. */
public static final String STATUS_SUCCESS = "00";
public static final String STATUS_RECORD_NOT_FOUND = "01";
public static final String STATUS_INVALID_RRN = "02";
/** Section 7 code mapping, used in remarks and in the API log. */
public static final String INVALID_ACCOUNT_NUMBER = "40001";
public static final String ACCOUNT_NOT_FOUND = "40002";
public static final String INVALID_AMOUNT = "40004";
public static final String INVALID_DATE = "40005";
public static final String UNAUTHORIZED = "40101";
public static final String SESSION_EXPIRED = "40102";
public static final String FORBIDDEN = "40301";
public static final String INSUFFICIENT_BALANCE = "50001";
public static final String LIEN_EXISTS = "50002";
public static final String DUPLICATE_TRANSACTION = "50003";
public static final String RRN_MISMATCH = "50006";
public static final String SERVICE_UNAVAILABLE = "60002";
public static final String DECRYPTION_ERROR = "60015";
public static final String MAINTENANCE = "70004";
public static final String REQUEST_TOO_LARGE = "70005";
public static final String RATE_LIMIT = "70006";
public static final String UNMAPPED = "70010";
/** Section 6: txn_type / txn_type_id pairs, spelt exactly as the specification prints them. */
public enum TxnType {
PUT_ON_HOLD(1, "Transaction Put on Hold"), //
MONEY_TRANSFER_TO(3, "Money Transfer To"), //
WITHDRAWAL_ATM(5, "Withdrawal through ATM"), //
WITHDRAWAL_AEPS(6, "Withdrawal through AEPS"), //
WITHDRAWAL_POS(11, "Withdrawal through POS"), //
WITHDRAWAL_CHEQUE(14, "Withdrawal through Cheque"), //
OTHERS_UPTO_500(17, "Others Less than equal to 500");
private final int id;
private final String label;
TxnType(final int id, final String label) {
this.id = id;
this.label = label;
}
public int id() {
return id;
}
public String label() {
return label;
}
}
}
@@ -0,0 +1,41 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* One complaint push from I4C (specification section 4, "Request Message from I4C-MHA to BANKS"), as parsed: the
* instrument fields plus its incidents.
*/
public record I4cComplaint(String acknowledgementNo, String subCategory, String requestor, String payerBank, String payerBankCode,
String modeOfPayment, String payerMobileNumber, String payerAccountNumber, String state, String district, String transactionType,
String wallet, List<Incident> incidents) {
/** One entry of {@code incidents[]}. Card fields are present only for the card-fraud sub-categories. */
public record Incident(int seqNo, BigDecimal amount, String rrn, LocalDate transactionDate, String transactionTime,
BigDecimal disputedAmount, int layer, String cardFirst6, String cardLast4, String cardLength) {
}
public int maxLayer() {
return incidents.stream().mapToInt(Incident::layer).max().orElse(0);
}
}
@@ -0,0 +1,271 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.data;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Reads an I4C complaint push.
*
* Liberal on purpose. The specification's own samples are not consistent: {@code mode_of _payment} has a space in the
* key, {@code acknowledment_no} is misspelt in places, {@code incidents} appears both beside and inside
* {@code instrument}, amounts and layers come as strings or numbers, and dates as {@code dd-MM-yyyy} in the tables but
* {@code yyyy-MM-dd} in the samples. Keys are therefore matched after dropping case, spaces and underscores, and values
* are coerced. Rejecting a real fraud complaint over a key spelling would defeat the point of the integration.
*
* Strict only on what the bank needs to act: the acknowledgement number, the sub-category, the account, and per
* incident the amount, disputed amount, RRN, date and layer. Anything missing there is a structure validation failure
* (ack code 11).
*/
public final class I4cComplaintParser {
private static final List<DateTimeFormatter> DATE_FORMATS = List.of(DateTimeFormatter.ISO_LOCAL_DATE,
DateTimeFormatter.ofPattern("dd-MM-yyyy"), DateTimeFormatter.ofPattern("dd/MM/yyyy"), DateTimeFormatter.ofPattern("yyyy/MM/dd"),
DateTimeFormatter.ofPattern("dd-MM-yy"));
private static final int MAX_INCIDENTS = 200;
private I4cComplaintParser() {}
/**
* {@code codes} holds the section 7 code of each kind of fault found (40001 account number, 40004 amount, 40005
* date); a fault with no specific section 7 code adds nothing, and the ack then falls back to 11.
*/
public record Result(I4cComplaint complaint, List<String> errors, Set<String> codes) {
public boolean isValid() {
return errors.isEmpty();
}
/** The most specific section 7 code for the faults found, in the table's order; 11 when none applies. */
public String section7Code() {
for (final String code : List.of(I4cCodes.INVALID_ACCOUNT_NUMBER, I4cCodes.INVALID_AMOUNT, I4cCodes.INVALID_DATE)) {
if (codes.contains(code)) {
return code;
}
}
return I4cCodes.ACK_STRUCTURE_VALIDATION_FAILED;
}
}
public static Result parse(final JsonObject root) {
return parse(root, null, 0);
}
/**
* @param today
* the bank's business date; a transaction_date more than {@code maxFutureDays} after it is refused
* (section 7: 40005 "Invalid Date"). Null skips the check.
*/
public static Result parse(final JsonObject root, final LocalDate today, final int maxFutureDays) {
final List<String> errors = new ArrayList<>();
final Set<String> codes = new LinkedHashSet<>();
final Map<String, JsonElement> top = normalise(root);
final JsonObject instrumentObject = objectOrNull(top.get("instrument"));
final Map<String, JsonElement> instrument = instrumentObject == null ? Map.of() : normalise(instrumentObject);
final String ack = firstText(top, instrument, "acknowledgementno", "acknowledmentno", "acknowledgmentno", "ackno");
if (ack == null) {
errors.add("acknowledgement_no is required");
} else if (!ack.matches("[A-Za-z0-9-]{1,40}")) {
errors.add("acknowledgement_no must be 1-40 letters/digits");
}
final String subCategory = firstText(top, instrument, "subcategory");
if (subCategory == null) {
errors.add("sub_category is required");
}
final String payerAccount = firstText(top, instrument, "payeraccountnumber", "accountnumber");
if (payerAccount == null) {
errors.add("instrument.payer_account_number is required");
} else if (!payerAccount.matches("[A-Za-z0-9@,.\\-]{1,40}")) {
errors.add("instrument.payer_account_number has characters outside a-z A-Z 0-9 @ , . -");
codes.add(I4cCodes.INVALID_ACCOUNT_NUMBER);
}
JsonElement incidentsElement = top.get("incidents");
if (incidentsElement == null) {
incidentsElement = instrument.get("incidents");
}
final List<I4cComplaint.Incident> incidents = new ArrayList<>();
if (incidentsElement == null || !incidentsElement.isJsonArray() || incidentsElement.getAsJsonArray().isEmpty()) {
errors.add("incidents must be a non-empty array");
} else {
final JsonArray array = incidentsElement.getAsJsonArray();
if (array.size() > MAX_INCIDENTS) {
errors.add("incidents has more than " + MAX_INCIDENTS + " entries");
} else {
for (int i = 0; i < array.size(); i++) {
final JsonObject incidentObject = objectOrNull(array.get(i));
if (incidentObject == null) {
errors.add("incidents[" + i + "] must be an object");
continue;
}
final I4cComplaint.Incident incident = parseIncident(i, normalise(incidentObject), errors, codes, today, maxFutureDays);
if (incident != null) {
incidents.add(incident);
}
}
}
}
if (!errors.isEmpty()) {
return new Result(null, errors, codes);
}
final I4cComplaint complaint = new I4cComplaint(ack, subCategory, firstText(top, instrument, "requestor"),
firstText(top, instrument, "payerbank"), firstText(top, instrument, "payerbankcode"),
firstText(top, instrument, "modeofpayment"), firstText(top, instrument, "payermobilenumber"), payerAccount,
firstText(top, instrument, "state"), firstText(top, instrument, "district"), firstText(top, instrument, "transactiontype"),
firstText(top, instrument, "wallet"), incidents);
return new Result(complaint, errors, codes);
}
private static I4cComplaint.Incident parseIncident(final int index, final Map<String, JsonElement> f, final List<String> errors,
final Set<String> codes, final LocalDate today, final int maxFutureDays) {
final String where = "incidents[" + index + "].";
final int before = errors.size();
final BigDecimal amount = decimal(f, "amount", where, errors, codes);
final BigDecimal disputed = decimal(f, "disputedamount", where, errors, codes);
final String rrn = text(f.get("rrn"));
if (rrn == null) {
errors.add(where + "rrn is required");
} else if (rrn.length() > 64) {
errors.add(where + "rrn is longer than 64 characters");
}
final String dateText = text(f.get("transactiondate"));
LocalDate date = null;
if (dateText == null) {
errors.add(where + "transaction_date is required");
} else {
date = parseDate(dateText);
if (date == null) {
errors.add(where + "transaction_date '" + dateText + "' is not a date (yyyy-MM-dd or dd-MM-yyyy)");
codes.add(I4cCodes.INVALID_DATE);
} else if (today != null && date.isAfter(today.plusDays(Math.max(0, maxFutureDays)))) {
errors.add(where + "transaction_date " + date + " is in the future");
codes.add(I4cCodes.INVALID_DATE);
}
}
final String layerText = text(f.get("layer"));
int layer = -1;
if (layerText == null) {
errors.add(where + "layer is required");
} else {
try {
layer = new BigDecimal(layerText).intValueExact();
if (layer < 0) {
errors.add(where + "layer cannot be negative");
}
} catch (final NumberFormatException | ArithmeticException e) {
errors.add(where + "layer '" + layerText + "' is not a whole number");
}
}
if (errors.size() > before) {
return null;
}
return new I4cComplaint.Incident(index + 1, amount, rrn, date, text(f.get("transactiontime")), disputed, layer,
text(f.get("first6digit")), text(f.get("last4digit")), text(f.get("cardlength")));
}
private static BigDecimal decimal(final Map<String, JsonElement> f, final String key, final String where, final List<String> errors,
final Set<String> codes) {
final String value = text(f.get(key));
if (value == null) {
errors.add(where + key + " is required");
return null;
}
try {
final BigDecimal parsed = new BigDecimal(value.replace(",", ""));
if (parsed.signum() <= 0) {
errors.add(where + key + " must be greater than zero");
codes.add(I4cCodes.INVALID_AMOUNT);
return null;
}
return parsed;
} catch (final NumberFormatException e) {
errors.add(where + key + " '" + value + "' is not an amount");
codes.add(I4cCodes.INVALID_AMOUNT);
return null;
}
}
static LocalDate parseDate(final String value) {
final String trimmed = value.trim();
// A date-time ("2023-01-02 15:02" or ISO with T) still carries the date we need.
final String datePart = trimmed.length() > 10 && (trimmed.charAt(10) == ' ' || trimmed.charAt(10) == 'T') ? trimmed.substring(0, 10)
: trimmed;
for (final DateTimeFormatter format : DATE_FORMATS) {
try {
return LocalDate.parse(datePart, format);
} catch (final DateTimeParseException ignored) {
// try the next one
}
}
return null;
}
private static String firstText(final Map<String, JsonElement> first, final Map<String, JsonElement> second, final String... keys) {
for (final String key : keys) {
String value = text(first.get(key));
if (value == null) {
value = text(second.get(key));
}
if (value != null) {
return value;
}
}
return null;
}
private static Map<String, JsonElement> normalise(final JsonObject object) {
final Map<String, JsonElement> out = new HashMap<>();
for (final Map.Entry<String, JsonElement> e : object.entrySet()) {
out.putIfAbsent(normaliseKey(e.getKey()), e.getValue());
}
return out;
}
static String normaliseKey(final String key) {
return key.replaceAll("[\\s_\\-.]", "").toLowerCase(Locale.ROOT);
}
private static JsonObject objectOrNull(final JsonElement element) {
return element != null && element.isJsonObject() ? element.getAsJsonObject() : null;
}
private static String text(final JsonElement element) {
if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
return null;
}
final String value = element.getAsString().trim();
return value.isEmpty() ? null : value;
}
}
@@ -0,0 +1,128 @@
/**
* 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;
};
}
}
@@ -0,0 +1,270 @@
/**
* 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;
}
}
@@ -0,0 +1,578 @@
/**
* 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();
}
}
@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Records every call between the bank and I4C, in both directions.
*
* The RBI letter (para 4) says the API integration's performance "shall be periodically reviewed to minimise the
* failures"; this table is what that review reads (call counts, failures, response times). Credentials, tokens and PANs
* are masked before anything is written: the log is for operations, not a second copy of secrets.
*
* A failure to write the log never fails the call being logged.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class I4cApiLogService {
public static final String IN = "IN";
public static final String OUT = "OUT";
private static final int MAX_BODY = 20_000;
private static final Pattern SECRET_FIELDS = Pattern
.compile("(\"(?:password|username|access_token|client_secret|token)\"\\s*:\\s*\")([^\"]*)(\")", Pattern.CASE_INSENSITIVE);
private static final Pattern PAN = Pattern.compile("\\b([A-Za-z]{5})\\d{4}([A-Za-z])\\b");
private final JdbcTemplate jdbcTemplate;
public void record(final String direction, final String endpoint, final Integer httpStatus, final String responseCode,
final String acknowledgementNo, final long durationMs, final String clientIp, final String requestBody,
final String responseBody, final String error) {
try {
jdbcTemplate.update("""
INSERT INTO m_i4c_api_log (direction, endpoint, http_status, response_code, acknowledgement_no, duration_ms, client_ip,
request_body, response_body, error, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", direction, truncate(endpoint, 300), httpStatus, truncate(responseCode, 10), truncate(acknowledgementNo, 40),
durationMs, truncate(clientIp, 64), mask(requestBody), mask(responseBody), truncate(error, 1000),
Timestamp.from(Instant.now()));
} catch (final RuntimeException e) {
log.warn("I4C API log entry could not be written ({} {}): {}", direction, endpoint, e.getMessage());
}
}
static String mask(final String body) {
if (body == null) {
return null;
}
String masked = SECRET_FIELDS.matcher(body).replaceAll("$1***$3");
masked = PAN.matcher(masked).replaceAll("$1****$2");
return truncate(masked, MAX_BODY);
}
private static String truncate(final String value, final int max) {
return value == null || value.length() <= max ? value : value.substring(0, max);
}
}
@@ -0,0 +1,84 @@
/**
* 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);
}
}
@@ -0,0 +1,263 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.apache.fineract.portfolio.cfcfrms.i4c.crypto.I4cCryptoJsAes;
import org.apache.fineract.portfolio.cfcfrms.i4c.simulator.I4cSimulator;
import org.springframework.stereotype.Service;
/**
* Calls I4C (specification section 3, "Authenticate from BANKS to I4C", and section 6, the response message).
*
* The token is fetched with the username/password I4C issued to the bank, encrypted in the CryptoJS format when the
* crypto mode asks for it, and reused until it nears expiry or I4C answers 401, when it is fetched again once. Timeouts
* are bounded so a slow I4C never holds up the dispatcher; failures surface to the outbox, which retries.
*/
@Slf4j
@Service
public class I4cClient {
private static final MediaType JSON = MediaType.get("application/json");
/** I4C does not publish its token lifetime; a token is reused for at most this long. */
private static final Duration TOKEN_REUSE = Duration.ofMinutes(10);
private final I4cProperties properties;
private final I4cApiLogService apiLog;
private final I4cSimulator simulator;
private volatile OkHttpClient httpClient;
private volatile String cachedToken;
private volatile Instant cachedTokenUntil = Instant.EPOCH;
public I4cClient(final I4cProperties properties, final I4cApiLogService apiLog, final I4cSimulator simulator) {
this.properties = properties;
this.apiLog = apiLog;
this.simulator = simulator;
}
public record Result(int httpStatus, String body, String responseCode) {
public boolean isSuccess() {
return httpStatus >= 200 && httpStatus < 300 && (responseCode == null || "00".equals(responseCode));
}
}
/** Posts one response message. Throws only on transport failure; an HTTP error comes back as a Result. */
public Result sendResponse(final String responseJson, final String acknowledgementNo) throws IOException {
return send(I4cOutboxService.TYPE_RESPONSE, responseJson, acknowledgementNo);
}
/** Posts a section 6 response (RESPONSE) or a section 5 ack (ACK) to its configured path. */
public Result send(final String messageType, final String json, final String acknowledgementNo) throws IOException {
requireConfigured();
final boolean ack = I4cOutboxService.TYPE_ACK.equals(messageType);
final String path = ack ? properties.getOutbound().getAckPath() : properties.getOutbound().getResponsePath();
if (path == null || path.isBlank()) {
throw new IOException("No I4C path configured for " + messageType + " messages");
}
if (I4cSimulator.INTERNAL_BASE_URL.equalsIgnoreCase(properties.getOutbound().getBaseUrl().trim())) {
return sendToSimulator(json, acknowledgementNo, path);
}
Result result = post(path, json, acknowledgementNo, token(false));
if (result.httpStatus() == 401) {
result = post(path, json, acknowledgementNo, token(true));
}
return result;
}
/**
* Local runs: the response goes to the in-process simulator, logged like a real call, so the whole flow can be
* tested where the server cannot reach itself over HTTPS (a self-signed dev certificate).
*/
private Result sendToSimulator(final String responseJson, final String acknowledgementNo, final String path) throws IOException {
if (!properties.getSimulator().isEnabled()) {
throw new IOException("outbound base-url is internal-simulator but fineract.i4c.simulator.enabled is false");
}
final long start = System.nanoTime();
final String body = simulator.receiveResponse(responseJson);
apiLog.record(I4cApiLogService.OUT, I4cSimulator.INTERNAL_BASE_URL + path, 200, "00",
acknowledgementNo, elapsedMs(start), null, responseJson, body, null);
return new Result(200, body, "00");
}
private Result post(final String path, final String responseJson, final String acknowledgementNo, final String token)
throws IOException {
final String body = wrapBody(responseJson);
final String url = url(path);
final Request request = new Request.Builder().url(url).post(RequestBody.create(body, JSON))
.header("Authorization", "Bearer " + token).header("Accept", "application/json").build();
final long start = System.nanoTime();
try (Response response = client().newCall(request).execute()) {
final String responseBody = unwrapBody(bodyOf(response.body()));
final String code = responseCodeOf(responseBody);
apiLog.record(I4cApiLogService.OUT, url, response.code(), code, acknowledgementNo, elapsedMs(start), null, responseJson,
responseBody, null);
return new Result(response.code(), responseBody, code);
} catch (final IOException e) {
apiLog.record(I4cApiLogService.OUT, url, null, null, acknowledgementNo, elapsedMs(start), null, responseJson, null,
e.getClass().getSimpleName() + ": " + e.getMessage());
throw e;
}
}
private synchronized String token(final boolean forceRefresh) throws IOException {
if (!forceRefresh && cachedToken != null && Instant.now().isBefore(cachedTokenUntil)) {
return cachedToken;
}
final I4cProperties.Outbound outbound = properties.getOutbound();
final JsonObject credentials = new JsonObject();
credentials.addProperty("username", encryptField(outbound.getUsername()));
credentials.addProperty("password", encryptField(outbound.getPassword()));
final String url = url(outbound.getAuthPath());
final Request request = new Request.Builder().url(url).post(RequestBody.create(credentials.toString(), JSON))
.header("Accept", "application/json").build();
final long start = System.nanoTime();
try (Response response = client().newCall(request).execute()) {
final String responseBody = bodyOf(response.body());
apiLog.record(I4cApiLogService.OUT, url, response.code(), null, null, elapsedMs(start), null, credentials.toString(),
responseBody, response.isSuccessful() ? null : "token request refused");
if (!response.isSuccessful()) {
throw new IOException("I4C token request returned HTTP " + response.code());
}
final JsonElement parsed = JsonParser.parseString(responseBody);
if (!parsed.isJsonObject() || !parsed.getAsJsonObject().has("access_token")) {
throw new IOException("I4C token response has no access_token");
}
cachedToken = parsed.getAsJsonObject().get("access_token").getAsString();
cachedTokenUntil = Instant.now().plus(TOKEN_REUSE);
return cachedToken;
} catch (final IOException e) {
cachedToken = null;
if (e.getMessage() == null || !e.getMessage().startsWith("I4C token")) {
apiLog.record(I4cApiLogService.OUT, url, null, null, null, elapsedMs(start), null, credentials.toString(), null,
e.getClass().getSimpleName() + ": " + e.getMessage());
}
throw e;
}
}
private String encryptField(final String value) {
final I4cProperties.Crypto crypto = properties.getCrypto();
if (value == null) {
return "";
}
if (crypto.getMode() != I4cProperties.CryptoMode.NONE && crypto.hasPassphrase()) {
return I4cCryptoJsAes.encrypt(value, crypto.getPassphrase());
}
return value;
}
private String wrapBody(final String json) {
final I4cProperties.Crypto crypto = properties.getCrypto();
if (crypto.getMode() == I4cProperties.CryptoMode.ENVELOPE && crypto.hasPassphrase()) {
final JsonObject envelope = new JsonObject();
envelope.addProperty("data", I4cCryptoJsAes.encrypt(json, crypto.getPassphrase()));
return envelope.toString();
}
return json;
}
private String unwrapBody(final String body) {
final I4cProperties.Crypto crypto = properties.getCrypto();
if (crypto.getMode() != I4cProperties.CryptoMode.ENVELOPE || !crypto.hasPassphrase() || body == null || body.isBlank()) {
return body;
}
try {
final JsonElement parsed = JsonParser.parseString(body);
if (parsed.isJsonObject() && parsed.getAsJsonObject().has("data")
&& I4cCryptoJsAes.looksEncrypted(parsed.getAsJsonObject().get("data").getAsString())) {
return I4cCryptoJsAes.decrypt(parsed.getAsJsonObject().get("data").getAsString(), crypto.getPassphrase());
}
} catch (final RuntimeException e) {
log.debug("I4C response body was not an encrypted envelope: {}", e.getMessage());
}
return body;
}
/** I4C's own response code, from {@code meta.response_code} or {@code response_code}, when the body has one. */
static String responseCodeOf(final String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
final JsonElement parsed = JsonParser.parseString(body);
if (!parsed.isJsonObject()) {
return null;
}
final JsonObject o = parsed.getAsJsonObject();
if (o.has("meta") && o.get("meta").isJsonObject() && o.getAsJsonObject("meta").has("response_code")) {
return o.getAsJsonObject("meta").get("response_code").getAsString();
}
return o.has("response_code") ? o.get("response_code").getAsString() : null;
} catch (final RuntimeException e) {
return null;
}
}
private OkHttpClient client() {
if (httpClient == null) {
synchronized (this) {
if (httpClient == null) {
final int timeout = Math.max(1000, properties.getOutbound().getTimeoutMs());
httpClient = new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS)
.readTimeout(timeout, TimeUnit.MILLISECONDS).writeTimeout(timeout, TimeUnit.MILLISECONDS)
.callTimeout(timeout * 2L, TimeUnit.MILLISECONDS).build();
}
}
}
return httpClient;
}
private void requireConfigured() throws IOException {
if (!properties.getOutbound().isConfigured()) {
throw new IOException("fineract.i4c.outbound.base-url is not configured");
}
}
private String url(final String path) {
String base = properties.getOutbound().getBaseUrl().trim();
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
final String p = path == null ? "" : path.trim();
return base + (p.startsWith("/") || p.isEmpty() ? p : "/" + p);
}
private static String bodyOf(final ResponseBody body) throws IOException {
return body == null ? "" : body.string();
}
private static long elapsedMs(final long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000L;
}
}
@@ -0,0 +1,401 @@
/**
* 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);
}
}
@@ -0,0 +1,176 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Orders, notices, bonds, FIRs, KYC and statements behind an I4C hold, a GRM grievance or an MRM restoration. Kept in
* the database with a SHA-256, never changed once uploaded, so the record shows exactly what the decision rested on.
*/
@Service
@RequiredArgsConstructor
public class I4cDocumentService {
public static final long MAX_BYTES = 5L * 1024 * 1024;
public static final Set<String> OWNER_TYPES = Set.of("HOLD", "GRIEVANCE", "RESTORATION");
public static final Set<String> DOC_TYPES = Set.of("IO_ORDER", "COURT_ORDER", "GRM_ORDER", "BNSS_106_NOTICE", "INDEMNITY_BOND", "FIR",
"KYC", "APPLICATION", "PHOTO", "BANK_STATEMENT", "EDD_REPORT", "PAN", "OTHER");
public static final Set<String> CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
private final JdbcTemplate jdbcTemplate;
/** A stored file: what the download endpoint sends back. */
public record StoredFile(String fileName, String contentType, byte[] content) {
}
public Long upload(final String ownerType, final Long ownerId, final String docType, final String fileName, final String contentType,
final String base64Content, final String remarks, final String uploadedBy) {
final String owner = upper(ownerType);
if (!OWNER_TYPES.contains(owner)) {
throw error("document.owner", "Documents attach to a HOLD, GRIEVANCE or RESTORATION.");
}
final String type = upper(docType);
if (!DOC_TYPES.contains(type)) {
throw error("document.type", "Document type must be one of " + DOC_TYPES + ".");
}
final String mime = contentType == null ? "" : contentType.trim().toLowerCase(Locale.ROOT);
if (!CONTENT_TYPES.contains(mime)) {
throw error("document.format", "Only PDF, JPEG and PNG files can be attached.");
}
if (fileName == null || fileName.isBlank()) {
throw error("document.name", "The file name is required.");
}
requireOwner(owner, ownerId);
final byte[] content;
try {
content = Base64.getDecoder().decode(base64Content == null ? "" : base64Content.replaceAll("\\s", ""));
} catch (final IllegalArgumentException e) {
throw error("document.encoding", "The file content must be Base64 encoded.", e);
}
if (content.length == 0) {
throw error("document.empty", "The file is empty.");
}
if (content.length > MAX_BYTES) {
throw error("document.size", "A document can be at most 5 MB.");
}
if (!matchesType(mime, content)) {
throw error("document.mismatch", "The file content does not look like a " + mime + " file.");
}
return jdbcTemplate.queryForObject("""
INSERT INTO m_i4c_document (owner_type, owner_id, doc_type, file_name, content_type, size_bytes, sha256, content, remarks,
uploaded_by, uploaded_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""", Long.class, owner, ownerId, type, safeName(fileName), mime, (long) content.length, sha256(content), content,
trim(remarks, 500), trim(uploadedBy, 100), Timestamp.from(Instant.now()));
}
/** The documents of one record, without their content. */
public List<Map<String, Object>> list(final String ownerType, final Long ownerId) {
return jdbcTemplate.queryForList("""
SELECT id, owner_type, owner_id, doc_type, file_name, content_type, size_bytes, sha256, remarks, uploaded_by, uploaded_at
FROM m_i4c_document WHERE owner_type = ? AND owner_id = ? ORDER BY id DESC
""", upper(ownerType), ownerId);
}
/** Which of {@code required} document types the record does not have yet. */
public List<String> missing(final String ownerType, final Long ownerId, final List<String> required) {
final List<String> present = jdbcTemplate.queryForList(
"SELECT DISTINCT doc_type FROM m_i4c_document WHERE owner_type = ? AND owner_id = ?", String.class, upper(ownerType), ownerId);
return required.stream().filter(t -> !present.contains(t)).toList();
}
public StoredFile download(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT file_name, content_type, content FROM m_i4c_document WHERE id = ?", id);
if (rows.isEmpty()) {
throw error("document.not.found", "Document " + id + " not found.");
}
final Map<String, Object> r = rows.get(0);
return new StoredFile((String) r.get("file_name"), (String) r.get("content_type"), (byte[]) r.get("content"));
}
private void requireOwner(final String owner, final Long ownerId) {
final String table = switch (owner) {
case "HOLD" -> "m_i4c_hold";
case "GRIEVANCE" -> "m_i4c_grievance";
default -> "m_i4c_restoration";
};
final Integer n = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table + " WHERE id = ?", Integer.class, ownerId);
if (n == null || n == 0) {
throw error("document.owner.missing", owner + " " + ownerId + " not found.");
}
}
/** First bytes of the file, so a renamed executable is not stored as a PDF. */
static boolean matchesType(final String mime, final byte[] c) {
return switch (mime) {
case "application/pdf" -> c.length > 4 && new String(c, 0, 5, StandardCharsets.US_ASCII).equals("%PDF-");
case "image/png" -> c.length > 8 && (c[0] & 0xFF) == 0x89 && c[1] == 'P' && c[2] == 'N' && c[3] == 'G';
case "image/jpeg" -> c.length > 3 && (c[0] & 0xFF) == 0xFF && (c[1] & 0xFF) == 0xD8 && (c[2] & 0xFF) == 0xFF;
default -> false;
};
}
static String safeName(final String name) {
final String base = name.replace('\\', '/');
final String last = base.substring(base.lastIndexOf('/') + 1).replaceAll("[^A-Za-z0-9._ -]", "_").trim();
final String out = last.isEmpty() ? "document" : last;
return out.length() <= 255 ? out : out.substring(out.length() - 255);
}
private static String sha256(final byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (final NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private static String upper(final String value) {
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
}
private static String trim(final String value, final int max) {
if (value == null || value.isBlank()) {
return null;
}
final String t = value.trim();
return t.length() <= max ? t : t.substring(0, max);
}
private static PlatformDataIntegrityException error(final String code, final String message, final Object... cause) {
return new PlatformDataIntegrityException("error.msg.i4c." + code, message, cause);
}
}
@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import java.util.concurrent.ThreadPoolExecutor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* A small pool of its own for I4C processing, so a burst of complaints (a fraud ring reported at once) neither waits
* behind nor starves the rest of the CBS's background work. When the queue is full the work runs on the caller's thread
* rather than being dropped: slower acks are better than a complaint nobody processes until the next job run.
*/
@Configuration
public class I4cExecutorConfig {
public static final String EXECUTOR = "i4cExecutor";
@Bean(name = EXECUTOR)
public TaskExecutor i4cExecutor() {
final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("i4c-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
@@ -0,0 +1,391 @@
/**
* 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);
}
}
@@ -0,0 +1,491 @@
/**
* 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);
}
}
@@ -0,0 +1,288 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import com.google.gson.JsonObject;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.apache.fineract.portfolio.cfcfrms.i4c.crypto.I4cCryptoJsAes;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
/**
* Authenticates I4C when it calls the bank (specification section 2: "Authenticate from I4C-MHA to BANKS").
*
* I4C posts the username and password the bank issued to it and receives an access token; every complaint push then
* carries that token as a Bearer credential. The token is 32 random bytes; only its SHA-256 hash is stored, so a copy
* of the table is useless to anyone reading it. Each check also requires the caller's IP to be on the allowlist I4C
* publishes ("Public IPs will be shared separately"), so a leaked token alone is not enough.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class I4cInboundAuthService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final BCryptPasswordEncoder BCRYPT = new BCryptPasswordEncoder();
private final I4cProperties properties;
private final JdbcTemplate jdbcTemplate;
public record IssuedToken(String accessToken, long expiresInSeconds) {
}
/**
* Checks the posted credentials, which may arrive encrypted (CryptoJS format, when the crypto mode is FIELDS or
* ENVELOPE). Empty when they do not match or the integration has no inbound credentials configured.
*/
public Optional<IssuedToken> issueToken(final String username, final String password, final String clientIp) {
final I4cProperties.Inbound inbound = properties.getInbound();
if (isBlank(inbound.getUsername()) || (isBlank(inbound.getPasswordHash()) && isBlank(inbound.getPassword()))) {
log.warn("I4C token request refused: fineract.i4c.inbound credentials are not configured");
return Optional.empty();
}
if (!credentialsMatch(username, password)) {
return Optional.empty();
}
final byte[] raw = new byte[32];
RANDOM.nextBytes(raw);
final String token = Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
final Instant now = Instant.now();
final long ttl = Math.max(60, inbound.getTokenTtlSeconds());
jdbcTemplate.update("INSERT INTO m_i4c_access_token (token_hash, client_ip, issued_at, expires_at, revoked) VALUES (?,?,?,?,?)",
sha256(token), clientIp, Timestamp.from(now), Timestamp.from(now.plusSeconds(ttl)), false);
return Optional.of(new IssuedToken(token, ttl));
}
/** A Bearer token issued by {@link #issueToken} that has not expired or been revoked. */
public boolean isValidToken(final String authorizationHeader) {
if (authorizationHeader == null || !authorizationHeader.regionMatches(true, 0, "Bearer ", 0, 7)) {
return false;
}
final String token = authorizationHeader.substring(7).trim();
if (token.isEmpty() || token.length() > 200) {
return false;
}
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_i4c_access_token WHERE token_hash = ? AND revoked = false AND expires_at > ?", Integer.class,
sha256(token), Timestamp.from(Instant.now()));
return count != null && count > 0;
}
/** Which of the specification's authentication forms a complaint push used; null when none was valid. */
public I4cProperties.AuthMode authenticatePush(final String authorizationHeader, final JsonObject body) {
final I4cProperties.Inbound inbound = properties.getInbound();
if (inbound.allows(I4cProperties.AuthMode.TOKEN) && isValidToken(authorizationHeader)) {
return I4cProperties.AuthMode.TOKEN;
}
if (inbound.allows(I4cProperties.AuthMode.BASIC) && isValidBasic(authorizationHeader)) {
return I4cProperties.AuthMode.BASIC;
}
if (inbound.allows(I4cProperties.AuthMode.BODY) && body != null) {
final String[] credentials = credentialsIn(body);
if (credentials != null && credentialsMatch(credentials[0], credentials[1])) {
return I4cProperties.AuthMode.BODY;
}
}
return null;
}
/**
* Authorization: Basic base64(user:password) - specification section 2, "Basic Auth / Auth 2.0". The user and password
* inside may themselves be CryptoJS-encrypted.
*/
public boolean isValidBasic(final String authorizationHeader) {
final String[] credentials = basicCredentials(authorizationHeader);
return credentials != null && credentialsMatch(credentials[0], credentials[1]);
}
/** user and password from an Authorization: Basic header, or null when it is not one. */
public static String[] basicCredentials(final String authorizationHeader) {
if (authorizationHeader == null || !authorizationHeader.regionMatches(true, 0, "Basic ", 0, 6)) {
return null;
}
try {
final String decoded = new String(Base64.getDecoder().decode(authorizationHeader.substring(6).trim()), StandardCharsets.UTF_8);
final int colon = decoded.indexOf(':');
return colon < 0 ? null : new String[] { decoded.substring(0, colon), decoded.substring(colon + 1) };
} catch (final IllegalArgumentException e) {
return null;
}
}
/**
* The credentials a message carries in its own JSON (section 2: "the bank will extract the user ID and password from
* the request JSON message"), under username/password or the OAuth names client_id/client_secret. They are removed
* from the message once read, so they are never stored with it.
*/
public static String[] credentialsIn(final JsonObject body) {
final String user = firstText(body, "username", "user_id", "userid", "client_id", "clientId");
final String pass = firstText(body, "password", "client_secret", "clientSecret");
for (final String key : new String[] { "username", "user_id", "userid", "client_id", "clientId", "password", "client_secret",
"clientSecret" }) {
body.remove(key);
}
return user == null || pass == null ? null : new String[] { user, pass };
}
/**
* True when the user and password are the ones issued to I4C. Each may arrive plain, CryptoJS-encrypted (the
* specification's samples), or plain Base64 (its text says "Base64-encoded"); every form is tried, in constant time.
*/
public boolean credentialsMatch(final String username, final String password) {
final I4cProperties.Inbound inbound = properties.getInbound();
if (isBlank(inbound.getUsername()) || (isBlank(inbound.getPasswordHash()) && isBlank(inbound.getPassword()))) {
return false;
}
boolean userOk = false;
for (final String candidate : forms(username)) {
userOk |= MessageDigest.isEqual(candidate.getBytes(StandardCharsets.UTF_8), inbound.getUsername().getBytes(StandardCharsets.UTF_8));
}
if (!userOk) {
return false;
}
for (final String candidate : forms(password)) {
if (passwordMatches(candidate, inbound)) {
return true;
}
}
return false;
}
/** The value as sent, decrypted when it is in the Salted__ format, and Base64-decoded when it is printable Base64. */
private List<String> forms(final String value) {
if (value == null) {
return List.of();
}
final List<String> out = new java.util.ArrayList<>();
final String decrypted = decryptIfNeeded(value);
if (decrypted != null) {
out.add(decrypted);
}
if (!I4cCryptoJsAes.looksEncrypted(value) && value.length() >= 4 && value.length() % 4 == 0 && value.matches("[A-Za-z0-9+/]+={0,2}")) {
try {
final String decoded = new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8);
if (!decoded.isEmpty() && decoded.chars().allMatch(c -> c >= 0x20 && c != 0x7f && c != 0xfffd)) {
out.add(decoded);
}
} catch (final IllegalArgumentException ignored) {
// not Base64 after all
}
}
return out;
}
private static String firstText(final JsonObject body, final String... keys) {
for (final String key : keys) {
if (body.has(key) && body.get(key).isJsonPrimitive()) {
final String value = body.get(key).getAsString();
if (!value.isBlank()) {
return value;
}
}
}
return null;
}
public boolean isIpAllowed(final String clientIp) {
final List<String> allowed = properties.getInbound().allowedIpList();
return allowed.isEmpty() || (clientIp != null && allowed.contains(clientIp));
}
/**
* The caller's address. X-Forwarded-For is honoured only when configured, because a client can set that header
* itself; behind a reverse proxy the proxy's own address would otherwise be the only one seen.
*/
public String clientIp(final HttpServletRequest request) {
if (properties.getInbound().isTrustForwardedFor()) {
final String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
}
return request.getRemoteAddr();
}
/** Tokens expired for more than a day are of no further use, even for audit (the API log keeps the calls). */
public int purgeExpiredTokens() {
return jdbcTemplate.update("DELETE FROM m_i4c_access_token WHERE expires_at < ?",
Timestamp.from(Instant.now().minusSeconds(86_400)));
}
private String decryptIfNeeded(final String value) {
if (value == null) {
return null;
}
final I4cProperties.Crypto crypto = properties.getCrypto();
if (crypto.getMode() != I4cProperties.CryptoMode.NONE && I4cCryptoJsAes.looksEncrypted(value)) {
if (!crypto.hasPassphrase()) {
log.warn("I4C sent encrypted credentials but fineract.i4c.crypto.passphrase is not set");
return null;
}
try {
return I4cCryptoJsAes.decrypt(value, crypto.getPassphrase());
} catch (final I4cCryptoJsAes.I4cCryptoException e) {
log.warn("I4C credentials could not be decrypted: {}", e.getMessage());
return null;
}
}
return value;
}
private static boolean passwordMatches(final String plainPassword, final I4cProperties.Inbound inbound) {
if (!isBlank(inbound.getPasswordHash())) {
final String hash = inbound.getPasswordHash().startsWith("{bcrypt}") ? inbound.getPasswordHash().substring(8)
: inbound.getPasswordHash();
try {
return BCRYPT.matches(plainPassword, hash);
} catch (final IllegalArgumentException e) {
log.error("fineract.i4c.inbound.password-hash is not a bcrypt hash");
return false;
}
}
return MessageDigest.isEqual(plainPassword.getBytes(StandardCharsets.UTF_8),
inbound.getPassword().getBytes(StandardCharsets.UTF_8));
}
static String sha256(final String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (final NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private static boolean isBlank(final String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,355 @@
/**
* 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);
}
}
@@ -0,0 +1,125 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
/**
* The filters, date range and page of an officer list screen, read from the query string.
*
* {@code fromDate} / {@code toDate} are {@code yyyy-MM-dd} and both inclusive; {@code offset} and {@code limit} pick the
* page. Every other key is a screen-specific filter read with {@link #text} or {@link #upper}.
*/
public final class I4cListQuery {
public static final int MAX_LIMIT = 500;
private final Map<String, List<String>> params;
private final LocalDate fromDate;
private final LocalDate toDate;
private final int offset;
private final int limit;
private I4cListQuery(final Map<String, List<String>> params, final int defaultLimit) {
this.params = params == null ? Map.of() : params;
this.fromDate = date("fromDate");
this.toDate = date("toDate");
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
throw new PlatformDataIntegrityException("error.msg.i4c.date.range.invalid", "The To date cannot be before the From date.");
}
this.offset = Math.max(number("offset", 0), 0);
final int requested = number("limit", defaultLimit);
this.limit = requested <= 0 ? defaultLimit : Math.min(requested, MAX_LIMIT);
}
public static I4cListQuery of(final Map<String, List<String>> params, final int defaultLimit) {
return new I4cListQuery(params, defaultLimit);
}
/** A filter value, trimmed; {@code null} when absent or blank. */
public String text(final String key) {
final List<String> values = params.get(key);
if (values == null || values.isEmpty() || values.get(0) == null || values.get(0).isBlank()) {
return null;
}
return values.get(0).trim();
}
public String upper(final String key) {
final String value = text(key);
return value == null ? null : value.toUpperCase(Locale.ROOT);
}
public boolean flag(final String key) {
return "true".equalsIgnoreCase(text(key));
}
public Integer integer(final String key) {
final String value = text(key);
if (value == null) {
return null;
}
try {
return Integer.valueOf(value);
} catch (final NumberFormatException e) {
throw new PlatformDataIntegrityException("error.msg.i4c.filter.invalid", "Filter " + key + " must be a number.", e);
}
}
/** Start of the From day, for {@code column >= ?}. */
public Timestamp fromStart() {
return fromDate == null ? null : Timestamp.valueOf(fromDate.atStartOfDay());
}
/** Start of the day after To, for {@code column < ?}, so the whole To day is included. */
public Timestamp toEndExclusive() {
return toDate == null ? null : Timestamp.valueOf(toDate.plusDays(1).atStartOfDay());
}
public int offset() {
return offset;
}
public int limit() {
return limit;
}
private LocalDate date(final String key) {
final String value = text(key);
if (value == null) {
return null;
}
try {
return LocalDate.parse(value);
} catch (final DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.i4c.date.invalid", key + " must be a date in yyyy-MM-dd form.", e);
}
}
private int number(final String key, final int fallback) {
final Integer value = integer(key);
return value == null ? fallback : value;
}
}
@@ -0,0 +1,123 @@
/**
* 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);
}
}
@@ -0,0 +1,165 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import java.io.IOException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Responses waiting to reach I4C.
*
* A response is written here in the same transaction that finishes processing the complaint, and only then sent, so a
* failed send never loses what the bank found: it stays PENDING and is retried with growing gaps (30 s, 1 min, 2 min
* ... capped at an hour) up to {@code fineract.i4c.outbound.max-attempts}, after which it is FAILED and shows in the
* officer screen for a manual retry. Each message is claimed (PENDING to SENDING) before it is sent, so two dispatchers
* never send the same one.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class I4cOutboxService {
public static final String TYPE_RESPONSE = "RESPONSE";
/** Section 5 ack sent as a call of its own, when fineract.i4c.outbound.ack-path is set. */
public static final String TYPE_ACK = "ACK";
public static final String PENDING = "PENDING";
public static final String SENDING = "SENDING";
public static final String SENT = "SENT";
public static final String FAILED = "FAILED";
private static final long BASE_DELAY_SECONDS = 30;
private static final long MAX_DELAY_SECONDS = 3600;
/** A message left SENDING this long was abandoned by a crashed sender and is sent again. */
private static final long STUCK_SENDING_SECONDS = 600;
private final JdbcTemplate jdbcTemplate;
private final I4cClient client;
private final I4cProperties properties;
public Long enqueue(final Long requestId, final String payload) {
return enqueue(requestId, TYPE_RESPONSE, payload);
}
public Long enqueue(final Long requestId, final String messageType, final String payload) {
return jdbcTemplate.queryForObject("""
INSERT INTO m_i4c_outbox (request_id, message_type, payload, status, attempts, next_attempt_at, created_at)
VALUES (?,?,?,?,0,?,?)
RETURNING id
""", Long.class, requestId, messageType, payload, PENDING, Timestamp.from(Instant.now()), Timestamp.from(Instant.now()));
}
/** Sends everything due. Returns how many were delivered. */
public int dispatchDue() {
if (!properties.getOutbound().isConfigured()) {
return 0;
}
jdbcTemplate.update("UPDATE m_i4c_outbox SET status = ? WHERE status = ? AND next_attempt_at < ?", PENDING, SENDING,
Timestamp.from(Instant.now().minusSeconds(STUCK_SENDING_SECONDS)));
final List<Long> due = jdbcTemplate.queryForList(
"SELECT id FROM m_i4c_outbox WHERE status = ? AND next_attempt_at <= ?"
+ " ORDER BY CASE WHEN message_type = 'ACK' THEN 0 ELSE 1 END, next_attempt_at, id LIMIT 100", Long.class,
PENDING, Timestamp.from(Instant.now()));
int sent = 0;
for (final Long id : due) {
if (send(id)) {
sent++;
}
}
return sent;
}
/**
* Sends one message now if it is due; used right after processing so a reply does not wait for the next job run.
*/
public boolean send(final Long outboxId) {
final int claimed = jdbcTemplate.update("UPDATE m_i4c_outbox SET status = ?, next_attempt_at = ? WHERE id = ? AND status = ?",
SENDING, Timestamp.from(Instant.now()), outboxId, PENDING);
if (claimed == 0) {
return false;
}
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT o.payload, o.attempts, o.message_type, r.acknowledgement_no FROM m_i4c_outbox o JOIN m_i4c_request r ON r.id = o.request_id
WHERE o.id = ?
""", outboxId);
final int attempts = ((Number) row.get("attempts")).intValue() + 1;
try {
final I4cClient.Result result = client.send((String) row.get("message_type"), (String) row.get("payload"),
(String) row.get("acknowledgement_no"));
if (result.isSuccess()) {
jdbcTemplate.update("""
UPDATE m_i4c_outbox SET status = ?, attempts = ?, http_status = ?, response_body = ?, last_error = NULL, sent_at = ?
WHERE id = ?
""", SENT, attempts, result.httpStatus(), truncate(result.body()), Timestamp.from(Instant.now()), outboxId);
return true;
}
failed(outboxId, attempts, result.httpStatus(), result.body(),
"I4C answered HTTP " + result.httpStatus() + (result.responseCode() == null ? "" : " code " + result.responseCode()));
} catch (final IOException | RuntimeException e) {
failed(outboxId, attempts, null, null, e.getClass().getSimpleName() + ": " + e.getMessage());
}
return false;
}
/** Sends this complaint's pending messages of one type now - the ack before the response that follows it. */
public void sendPending(final Long requestId, final String messageType) {
for (final Long id : jdbcTemplate.queryForList("SELECT id FROM m_i4c_outbox WHERE request_id = ? AND message_type = ? AND status = ?",
Long.class, requestId, messageType, PENDING)) {
send(id);
}
}
/** Officer action: send a FAILED message again, with a fresh set of attempts. */
public void retry(final Long outboxId) {
final int updated = jdbcTemplate.update(
"UPDATE m_i4c_outbox SET status = ?, attempts = 0, next_attempt_at = ? WHERE id = ? AND status = ?", PENDING,
Timestamp.from(Instant.now()), outboxId, FAILED);
if (updated == 0) {
throw new PlatformDataIntegrityException("error.msg.i4c.outbox.not.failed", "Only a FAILED message can be retried.");
}
}
private void failed(final Long outboxId, final int attempts, final Integer httpStatus, final String body, final String error) {
final int max = Math.max(1, properties.getOutbound().getMaxAttempts());
final boolean giveUp = attempts >= max;
final long delay = Math.min(MAX_DELAY_SECONDS, BASE_DELAY_SECONDS * (1L << Math.min(attempts - 1, 20)));
jdbcTemplate.update("""
UPDATE m_i4c_outbox SET status = ?, attempts = ?, next_attempt_at = ?, http_status = ?, response_body = ?, last_error = ?
WHERE id = ?
""", giveUp ? FAILED : PENDING, attempts, Timestamp.from(Instant.now().plusSeconds(delay)), httpStatus, truncate(body),
truncate(error), outboxId);
if (giveUp) {
log.error("I4C response {} FAILED after {} attempts: {}", outboxId, attempts, error);
} else {
log.warn("I4C response {} attempt {} failed, next in {}s: {}", outboxId, attempts, delay, error);
}
}
private static String truncate(final String value) {
return value == null || value.length() <= 10_000 ? value : value.substring(0, 10_000);
}
}
@@ -0,0 +1,100 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
/**
* Processes complaints away from the request that delivered them.
*
* I4C expects the ack straight away (section 5), and acting on a complaint means account look-ups, holds and tracing
* that should not keep its connection open; so the ack is returned at once and the work happens on the I4C executor.
* The I4C Dispatch job picks up anything this did not finish (a restart in between, a failure to retry), so the
* executor is the fast path and never the only one.
*/
@Slf4j
@Component
public class I4cProcessingRunner {
/** A request left PROCESSING this long belonged to a worker that died; the dispatch job takes it over. */
private static final long STUCK_SECONDS = 600;
/** Gap before a RETRY request is tried again. */
private static final long RETRY_AFTER_SECONDS = 60;
// Deferred: the processor needs the request service's tables and the request service needs this runner.
private final ObjectProvider<I4cComplaintProcessor> processor;
private final ObjectProvider<I4cOutboxService> outbox;
private final ObjectProvider<I4cSystemContext> systemContext;
private final ObjectProvider<I4cIntimationService> intimation;
private final JdbcTemplate jdbcTemplate;
public I4cProcessingRunner(final ObjectProvider<I4cComplaintProcessor> processor, final ObjectProvider<I4cOutboxService> outbox,
final ObjectProvider<I4cSystemContext> systemContext, final ObjectProvider<I4cIntimationService> intimation,
final JdbcTemplate jdbcTemplate) {
this.processor = processor;
this.outbox = outbox;
this.systemContext = systemContext;
this.intimation = intimation;
this.jdbcTemplate = jdbcTemplate;
}
@Async(I4cExecutorConfig.EXECUTOR)
public void processAsync(final Long requestId, final FineractPlatformTenant tenant) {
systemContext.getObject().runInTenant(tenant, () -> processAndSend(requestId));
}
/** Processes one complaint and sends the reply immediately; the caller provides tenant and user context. */
public void processAndSend(final Long requestId) {
try {
final Long outboxId = processor.getObject().process(requestId);
// A separate ack call, when configured, must reach I4C before the response it acknowledges.
outbox.getObject().sendPending(requestId, I4cOutboxService.TYPE_ACK);
if (outboxId != null) {
outbox.getObject().send(outboxId);
}
// Tell the account holder as soon as money is held, on whichever automatic channel is switched on.
intimation.getObject().intimateForRequest(requestId);
} catch (final RuntimeException e) {
log.error("I4C request {} could not be processed now; the dispatch job will try again", requestId, e);
}
}
/** For the dispatch job: complaints received but not processed, retries that are due, and abandoned work. */
public int processPending() {
jdbcTemplate.update("UPDATE m_i4c_request SET status = ? WHERE status = ? AND received_at < ? AND processed_at IS NULL",
I4cComplaintProcessor.RETRY, I4cComplaintProcessor.PROCESSING, Timestamp.from(Instant.now().minusSeconds(STUCK_SECONDS)));
final List<Long> due = jdbcTemplate.queryForList("""
SELECT id FROM m_i4c_request
WHERE (status = ? AND received_at < ?) OR status = ?
ORDER BY received_at
LIMIT 50
""", Long.class, I4cComplaintProcessor.RECEIVED, Timestamp.from(Instant.now().minusSeconds(RETRY_AFTER_SECONDS)),
I4cComplaintProcessor.RETRY);
due.forEach(this::processAndSend);
return due.size();
}
}
@@ -0,0 +1,131 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cAcks;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cComplaint;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* Takes in a complaint push: stores it, answers with a job id, and starts processing once the store is committed.
*
* Idempotent per (acknowledgement_no, content). I4C retries a push when it does not get an ack in time; a repeat with
* the same incidents gets the same job_id back and starts nothing new. The same acknowledgement number with different
* incidents (the next layer of the same complaint, say) is a new request with its own job_id.
*/
@Service
@RequiredArgsConstructor
public class I4cRequestService {
private final JdbcTemplate jdbcTemplate;
private final I4cProcessingRunner runner;
private final I4cProperties properties;
private final I4cOutboxService outbox;
public record Accepted(Long requestId, String jobId, boolean duplicate) {
}
@Transactional
public Accepted accept(final I4cComplaint complaint, final String rawPayload, final String sourceIp) {
final String hash = I4cInboundAuthService.sha256(contentKey(complaint));
final List<java.util.Map<String, Object>> existing = jdbcTemplate.queryForList(
"SELECT id, job_id FROM m_i4c_request WHERE acknowledgement_no = ? AND payload_hash = ? ORDER BY id LIMIT 1",
complaint.acknowledgementNo(), hash);
if (!existing.isEmpty()) {
return new Accepted(((Number) existing.get(0).get("id")).longValue(), (String) existing.get(0).get("job_id"), true);
}
final String jobId = "BANKS-" + UUID.randomUUID();
final Long requestId = jdbcTemplate.queryForObject(
"""
INSERT INTO m_i4c_request (acknowledgement_no, job_id, sub_category, requestor, payer_bank, payer_bank_code, mode_of_payment,
payer_mobile_number, payer_account_number, state, district, transaction_type, wallet, max_layer,
payload_hash, raw_payload, source_ip, status, processing_attempts, received_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)
RETURNING id
""",
Long.class, complaint.acknowledgementNo(), jobId, cut(complaint.subCategory(), 200), cut(complaint.requestor(), 50),
cut(complaint.payerBank(), 200), cut(complaint.payerBankCode(), 10), cut(complaint.modeOfPayment(), 30),
cut(complaint.payerMobileNumber(), 20), complaint.payerAccountNumber(), cut(complaint.state(), 100),
cut(complaint.district(), 100), cut(complaint.transactionType(), 50), cut(complaint.wallet(), 100), complaint.maxLayer(),
hash, I4cApiLogService.mask(rawPayload), cut(sourceIp, 64), I4cComplaintProcessor.RECEIVED, Timestamp.from(Instant.now()));
for (final I4cComplaint.Incident i : complaint.incidents()) {
jdbcTemplate.update("""
INSERT INTO m_i4c_incident (request_id, seq_no, layer, amount, disputed_amount, rrn, transaction_date, transaction_time,
card_first6, card_last4, card_length)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", requestId, i.seqNo(), i.layer(), i.amount(), i.disputedAmount(), i.rrn(),
i.transactionDate() == null ? null : Date.valueOf(i.transactionDate()), cut(i.transactionTime(), 12),
cut(i.cardFirst6(), 6), cut(i.cardLast4(), 4), cut(i.cardLength(), 4));
}
final String ackPath = properties.getOutbound().getAckPath();
if (ackPath != null && !ackPath.isBlank()) {
// Written with the request, so it is queued before the response the processing will write.
outbox.enqueue(requestId, I4cOutboxService.TYPE_ACK, I4cAcks.ack(I4cCodes.ACK_SUCCESS, complaint.acknowledgementNo(), jobId));
}
startAfterCommit(requestId);
return new Accepted(requestId, jobId, false);
}
/**
* Processing starts only once the request is committed, so the worker never reads a request that is not there yet.
*/
private void startAfterCommit(final Long requestId) {
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
runner.processAsync(requestId, tenant);
}
});
} else {
runner.processAsync(requestId, tenant);
}
}
/** What makes two pushes "the same": the account and every incident's reference, amounts, date and layer. */
static String contentKey(final I4cComplaint complaint) {
final StringBuilder key = new StringBuilder(complaint.acknowledgementNo()).append('|').append(complaint.payerAccountNumber());
for (final I4cComplaint.Incident i : complaint.incidents()) {
key.append('|').append(i.rrn()).append(',').append(i.amount().stripTrailingZeros().toPlainString()).append(',')
.append(i.disputedAmount().stripTrailingZeros().toPlainString()).append(',').append(i.transactionDate()).append(',')
.append(i.layer());
}
return key.toString();
}
private static String cut(final String value, final int max) {
return value == null || value.length() <= max ? value : value.substring(0, max);
}
}
@@ -0,0 +1,259 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes;
import org.apache.fineract.portfolio.cfcfrms.i4c.data.I4cCodes.TxnType;
/**
* Writes the "Response Message from BANKS to I4C-MHA" (specification section 6): {@code acknowledgement_no},
* {@code job_id} and one {@code transactions[]} entry per finding.
*
* Field names follow the specification's success samples, which are what I4C parses; where the table and the sample
* disagree (the table says {@code rrn}, the Money Transfer sample says {@code rrn_transaction_id}) the sample wins.
* Every value is a string, amounts with two decimals, as in the samples.
*
* Layer 0 is the victim's own account: the specification makes phone, PAN, effective balance and root IFSC optional
* there and gives defaults ({@code 0000000000}, {@code "Layer 0"}, {@code "0"}), which are used.
*/
public final class I4cResponseBuilder {
static final String LAYER0_PHONE = "0000000000";
static final String LAYER0_TEXT = "Layer 0";
private I4cResponseBuilder() {}
/**
* What every entry of one incident shares: the root (this bank's) account, the reported RRN, the account holder.
*/
public record Root(String accountNumber, String rrn, int bankId, int layer, BigDecimal disputedAmount, BigDecimal effectiveBalance,
String phone, String email, String pan, String ifsc) {
}
public static JsonObject envelope(final String acknowledgementNo, final String jobId, final List<JsonObject> transactions) {
final JsonObject out = new JsonObject();
out.addProperty("acknowledgement_no", acknowledgementNo);
out.addProperty("job_id", jobId);
final JsonArray array = new JsonArray();
transactions.forEach(array::add);
out.add("transactions", array);
return out;
}
/** txn_type_id 1: the amount now held on the root account. */
public static JsonObject putOnHold(final Root root, final BigDecimal amountHeld, final String transactionDateTime,
final String remarks) {
final JsonObject e = new JsonObject();
e.addProperty("root_account_number", root.accountNumber());
e.addProperty("root_rrn_transaction_id", root.rrn());
e.addProperty("root_bankid", String.valueOf(root.bankId()));
e.addProperty("ifsc_code", nz(root.ifsc()));
e.addProperty("amount", money(amountHeld));
e.addProperty("transaction_datetime", nz(transactionDateTime));
holder(e, root);
e.addProperty("remarks", nz(remarks));
type(e, TxnType.PUT_ON_HOLD);
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
tail(e, root);
return e;
}
/** txn_type_id 3: money moved on to another account, here or at another bank. */
public static JsonObject moneyTransferTo(final Root root, final String rrnTransactionId, final String payeeBank,
final int payeeBankCode, final String payeeAccountNumber, final String payeeIfsc, final BigDecimal amount,
final BigDecimal disputedAmount, final String transactionDateTime, final String remarks) {
final JsonObject e = new JsonObject();
e.addProperty("rrn_transaction_id", nz(rrnTransactionId));
// The section 6 table names this key "rrn"; its sample "rrn_transaction_id". Both are sent, with one value.
e.addProperty("rrn", nz(rrnTransactionId));
e.addProperty("payee_bank", nz(payeeBank));
e.addProperty("payee_bank_code", String.valueOf(payeeBankCode));
e.addProperty("payee_account_number", nz(payeeAccountNumber));
e.addProperty("amount", money(amount));
e.addProperty("transaction_datetime", nz(transactionDateTime));
e.addProperty("disputed_amount", money(disputedAmount));
e.addProperty("ifsc_code", nz(payeeIfsc));
type(e, TxnType.MONEY_TRANSFER_TO);
holder(e, root);
e.addProperty("root_account_number", root.accountNumber());
e.addProperty("root_rrn_transaction_id", root.rrn());
e.addProperty("root_bankid", String.valueOf(root.bankId()));
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
e.addProperty("remarks", nz(remarks));
tail(e, root);
return e;
}
/** txn_type_id 14: withdrawn at the branch against a cheque. */
public static JsonObject withdrawalThroughCheque(final Root root, final String chequeNo, final String withdrawalDate,
final BigDecimal amount, final BigDecimal disputedAmount, final String location, final String managerName,
final String managerNumber, final String remarks) {
final JsonObject e = new JsonObject();
e.addProperty("root_account_number", root.accountNumber());
e.addProperty("root_rrn_transaction_id", root.rrn());
e.addProperty("root_bankid", String.valueOf(root.bankId()));
e.addProperty("account_number", root.accountNumber());
e.addProperty("ifsc_code", nz(root.ifsc()));
e.addProperty("cheque_no", nz(chequeNo));
e.addProperty("withdrawal_date", nz(withdrawalDate));
e.addProperty("amount", money(amount));
e.addProperty("disputed_amount", money(disputedAmount));
e.addProperty("location", nz(location));
e.addProperty("managername", nz(managerName));
e.addProperty("managernumber", nz(managerNumber));
holder(e, root);
e.addProperty("remarks", nz(remarks));
type(e, TxnType.WITHDRAWAL_CHEQUE);
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
tail(e, root);
return e;
}
/** txn_type_id 5: withdrawn at an ATM. Field order of the specification's ATM sample. */
public static JsonObject withdrawalThroughAtm(final Root root, final BigDecimal amount, final String transactionDateTime,
final String atmId, final String placeOfAtm, final String atmOfBank, final String remarks) {
final JsonObject e = rootHead(root);
e.addProperty("amount", money(amount));
e.addProperty("transaction_datetime", nz(transactionDateTime));
holder(e, root);
e.addProperty("disputed_amount", money(root.disputedAmount()));
e.addProperty("remarks", nz(remarks));
e.addProperty("atm_id", nz(atmId));
e.addProperty("place_of_atm", nz(placeOfAtm));
e.addProperty("atm_of_bank", nz(atmOfBank));
type(e, TxnType.WITHDRAWAL_ATM);
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
tail(e, root);
return e;
}
/** txn_type_id 6: withdrawn through AEPS. The table calls the reference "rrn", the sample "rrn_transaction_id". */
public static JsonObject withdrawalThroughAeps(final Root root, final String rrnTransactionId, final BigDecimal amount,
final String remarks) {
final JsonObject e = rootHead(root);
e.addProperty("rrn_transaction_id", nz(rrnTransactionId));
e.addProperty("rrn", nz(rrnTransactionId));
e.addProperty("amount", money(amount));
e.addProperty("disputed_amount", money(root.disputedAmount()));
holder(e, root);
e.addProperty("remarks", nz(remarks));
type(e, TxnType.WITHDRAWAL_AEPS);
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
tail(e, root);
return e;
}
/** txn_type_id 11: spent at a merchant's POS terminal. */
public static JsonObject withdrawalThroughPos(final Root root, final BigDecimal amount, final String transactionDateTime,
final String mid, final String tid, final String approvalCode, final String merchantName, final String posTransactionId,
final String remarks) {
final JsonObject e = rootHead(root);
e.addProperty("amount", money(amount));
e.addProperty("transaction_datetime", nz(transactionDateTime));
holder(e, root);
e.addProperty("disputed_amount", money(root.disputedAmount()));
e.addProperty("remarks", nz(remarks));
e.addProperty("mid", nz(mid));
e.addProperty("tid", nz(tid));
e.addProperty("approval_code", nz(approvalCode));
e.addProperty("merchant_name", nz(merchantName));
e.addProperty("pos_transaction_id", nz(posTransactionId));
type(e, TxnType.WITHDRAWAL_POS);
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
tail(e, root);
return e;
}
private static JsonObject rootHead(final Root root) {
final JsonObject e = new JsonObject();
e.addProperty("root_account_number", root.accountNumber());
e.addProperty("root_rrn_transaction_id", root.rrn());
e.addProperty("root_bankid", String.valueOf(root.bankId()));
return e;
}
/** txn_type_id 17: amounts of Rs 500 or less that are not traced further. */
public static JsonObject othersUpTo500(final Root root, final BigDecimal amount, final String remarks) {
final JsonObject e = new JsonObject();
e.addProperty("amount", money(amount));
e.addProperty("disputed_amount", money(root.disputedAmount()));
type(e, TxnType.OTHERS_UPTO_500);
holder(e, root);
e.addProperty("root_account_number", root.accountNumber());
e.addProperty("root_rrn_transaction_id", root.rrn());
e.addProperty("root_bankid", String.valueOf(root.bankId()));
e.addProperty("status_code", I4cCodes.STATUS_SUCCESS);
e.addProperty("remarks", nz(remarks));
tail(e, root);
return e;
}
/**
* An incident that could not be acted on: status_code 01 "Record not found" (no such account here) or 02 "Invalid
* RRN" (the account exists but the transaction does not). Shape of the specification's failed-transaction sample.
*/
public static JsonObject failure(final String rootAccountNumber, final String rrn, final int bankId, final BigDecimal amount,
final BigDecimal disputedAmount, final String transactionDateTime, final String statusCode, final String remarks) {
final JsonObject e = new JsonObject();
e.addProperty("root_rrn_transaction_id", nz(rrn));
e.addProperty("root_bankid", String.valueOf(bankId));
e.addProperty("root_account_number", nz(rootAccountNumber));
e.addProperty("amount", money(amount));
e.addProperty("transaction_datetime", nz(transactionDateTime));
e.addProperty("disputed_amount", money(disputedAmount));
e.addProperty("remarks", nz(remarks));
e.addProperty("status_code", statusCode);
return e;
}
private static void holder(final JsonObject e, final Root root) {
final boolean layer0 = root.layer() == 0;
e.addProperty("phone_number", layer0 && isBlank(root.phone()) ? LAYER0_PHONE : nz(root.phone()));
e.addProperty("email", nz(root.email()));
e.addProperty("pan_number", layer0 && isBlank(root.pan()) ? LAYER0_TEXT : nz(root.pan()));
}
private static void tail(final JsonObject e, final Root root) {
final boolean layer0 = root.layer() == 0;
e.addProperty("root_effective_balance", layer0 && root.effectiveBalance() == null ? "0" : money(root.effectiveBalance()));
e.addProperty("root_ifsc_code", layer0 && isBlank(root.ifsc()) ? LAYER0_TEXT : nz(root.ifsc()));
}
private static void type(final JsonObject e, final TxnType type) {
e.addProperty("txn_type", type.label());
e.addProperty("txn_type_id", String.valueOf(type.id()));
}
static String money(final BigDecimal value) {
return (value == null ? BigDecimal.ZERO : value).setScale(2, RoundingMode.HALF_UP).toPlainString();
}
private static String nz(final String value) {
return value == null ? "" : value;
}
private static boolean isBlank(final String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,221 @@
/**
* 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);
}
}
@@ -0,0 +1,545 @@
/**
* 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);
}
}
@@ -0,0 +1,94 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
/**
* Runs I4C work as the CBS "system" user.
*
* I4C's own calls arrive with no Fineract login, and the account write services read the signed-in user for their audit
* columns ({@code context.authenticatedUser()}), so the work is done as the built-in system user, the same identity
* scheduled jobs use (see JobStarter). Everything placed for I4C is then visibly the system's doing, never an
* officer's.
*/
@Component
@RequiredArgsConstructor
public class I4cSystemContext {
private final AppUserRepositoryWrapper userRepository;
private final TenantDetailsService tenantDetailsService;
private final BusinessDateReadPlatformService businessDateReadPlatformService;
/**
* Runs {@code work} as the system user in the tenant already on this thread, then puts the caller's identity back.
*/
public <T> T callAsSystem(final Supplier<T> work) {
final SecurityContext previous = SecurityContextHolder.getContext();
try {
final AppUser user = userRepository.fetchSystemUser();
final SecurityContext systemContext = SecurityContextHolder.createEmptyContext();
systemContext.setAuthentication(new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()));
SecurityContextHolder.setContext(systemContext);
return work.get();
} finally {
SecurityContextHolder.setContext(previous);
}
}
public void runAsSystem(final Runnable work) {
callAsSystem(() -> {
work.run();
return null;
});
}
/**
* For a thread with no context at all (a background worker): sets the tenant, its business dates and the system
* user, runs {@code work}, and clears everything again so a pooled thread never carries one tenant into the next
* task.
*/
public void runInTenant(final FineractPlatformTenant tenant, final Runnable work) {
try {
ThreadLocalContextUtil.setTenant(tenant);
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
runAsSystem(work);
} finally {
SecurityContextHolder.clearContext();
ThreadLocalContextUtil.reset();
}
}
public FineractPlatformTenant loadTenant(final String tenantIdentifier) {
return tenantDetailsService.loadTenantById(tenantIdentifier);
}
}
@@ -0,0 +1,361 @@
/**
* 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;
}
}
@@ -0,0 +1,119 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.simulator;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedDeque;
import org.springframework.stereotype.Component;
/**
* A stand-in for I4C's side of the integration, for local runs and UAT without the C-DOT VPN.
*
* It issues tokens, accepts the bank's response messages (keeping the last hundred so a tester can see exactly what the
* bank would have sent I4C), and builds complaint pushes in the specification's shape. Only ever active with
* {@code fineract.i4c.simulator.enabled=true}; never in production.
*/
@Component
public class I4cSimulator {
/** Outbound base URL that routes the bank's calls to this simulator in-process instead of over HTTP. */
public static final String INTERNAL_BASE_URL = "internal-simulator";
private static final int KEEP = 100;
private final Deque<JsonObject> received = new ConcurrentLinkedDeque<>();
public String issueToken() {
final JsonObject out = new JsonObject();
out.addProperty("access_token", "SIM-" + UUID.randomUUID());
return out.toString();
}
public String receiveResponse(final String body) {
final JsonObject entry = new JsonObject();
entry.addProperty("receivedAt", Instant.now().toString());
try {
entry.add("message", JsonParser.parseString(body));
} catch (final RuntimeException e) {
entry.addProperty("raw", body);
}
received.addFirst(entry);
while (received.size() > KEEP) {
received.pollLast();
}
final JsonObject meta = new JsonObject();
meta.addProperty("response_code", "00");
meta.addProperty("response_message", "Success");
final JsonObject out = new JsonObject();
out.add("meta", meta);
return out.toString();
}
public List<JsonObject> received() {
return new ArrayList<>(received);
}
/**
* A complaint push in the specification's section 4 shape, from the few values a tester types. Defaults: UPI fraud,
* layer 1, disputed amount equal to the amount, today's date.
*/
public static JsonObject buildComplaint(final JsonObject in) {
final String ack = text(in, "acknowledgementNo", "3" + String.valueOf(System.currentTimeMillis()).substring(0, 13));
final String amount = text(in, "amount", "1000.00");
final JsonObject instrument = new JsonObject();
instrument.addProperty("requestor", "I4C-MHA");
instrument.addProperty("payer_bank", text(in, "payerBank", "Simulated Bank"));
instrument.addProperty("payer_bank_code", text(in, "payerBankCode", "0"));
instrument.addProperty("mode_of_payment", "Debit/Credit");
instrument.addProperty("payer_mobile_number", text(in, "payerMobileNumber", "919999999999"));
instrument.addProperty("payer_account_number", text(in, "accountNumber", ""));
instrument.addProperty("state", text(in, "state", "DELHI"));
instrument.addProperty("district", text(in, "district", "South Delhi"));
instrument.addProperty("transaction_type", text(in, "transactionType", ""));
instrument.addProperty("wallet", text(in, "wallet", "Phonepe"));
final JsonObject incident = new JsonObject();
incident.addProperty("amount", amount);
incident.addProperty("rrn", text(in, "rrn", String.valueOf(System.nanoTime()).substring(0, 12)));
incident.addProperty("transaction_date", text(in, "transactionDate", java.time.LocalDate.now().toString()));
incident.addProperty("transaction_time", text(in, "transactionTime", "12:00:00"));
incident.addProperty("disputed_amount", text(in, "disputedAmount", amount));
incident.addProperty("layer", text(in, "layer", "1"));
final JsonArray incidents = new JsonArray();
incidents.add(incident);
final JsonObject out = new JsonObject();
out.addProperty("acknowledgement_no", ack);
out.addProperty("sub_category", text(in, "subCategory", "UPI Related Frauds"));
out.add("instrument", instrument);
out.add("incidents", incidents);
return out;
}
private static String text(final JsonObject json, final String key, final String fallback) {
return json.has(key) && !json.get(key).isJsonNull() && !json.get(key).getAsString().isBlank() ? json.get(key).getAsString()
: fallback;
}
}
@@ -0,0 +1,133 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.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();
}
}
@@ -69,6 +69,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -90,6 +92,8 @@ public class ClerkDisbursementRequestApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ────────
@@ -202,6 +206,9 @@ public class ClerkDisbursementRequestApiResource {
}
}
// Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account.
validateFixedDepositLegs(creditEntries, requestType);
// Validate balance when both DR and CR entries present (collection mode)
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
@@ -491,6 +498,10 @@ public class ClerkDisbursementRequestApiResource {
rejectRecurringDepositLeg(entry, entryType);
}
}
// Re-checked here, not only at submit: another request (or a counter deposit) may have funded the
// same FD while this one waited, and a refusal now still leaves the request PENDING.
final Set<Long> fundedFixedDeposits = validateFixedDepositLegs(creditEntries,
record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT");
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
// posting duplicate Fineract transactions for the same request.
@@ -773,6 +784,12 @@ public class ClerkDisbursementRequestApiResource {
}
} // end else (disbursement/collection mode)
// A funded FD's maturity date and amount were projected off its principal with no money in it;
// the direct credit above does not recompute them, so do it now that the money has landed.
for (final Long fdId : fundedFixedDeposits) {
depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId);
}
} catch (Exception ex) {
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
// This restores account balances even when a later entry fails mid-processing.
@@ -1223,6 +1240,70 @@ public class ClerkDisbursementRequestApiResource {
}
}
/**
* Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label.
* <ul>
* <li>An FD is never debited here: money leaves an FD only through its maturity or premature closure. The
* direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.</li>
* <li>An FD credit is its first and only funding, for exactly its principal, and an FD appears once.</li>
* <li>The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund
* them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is
* the bank's own money and has no customer to compare.</li>
* </ul>
*
* @return the ids of the FDs this request funds, for the maturity recalculation after posting
*/
private Set<Long> validateFixedDepositLegs(final JsonArray entries, final String requestType) {
final Set<Long> fdIds = new LinkedHashSet<>();
Long firstFdId = null;
Long fdClientId = null;
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) {
continue;
}
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId);
if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) {
throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only"
+ " through its maturity or premature closure.");
}
if (!fdIds.add(accountId)) {
throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once,"
+ " with its full principal in one entry.");
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount);
if (firstFdId == null) {
firstFdId = accountId;
fdClientId = clientId;
} else if (!Objects.equals(fdClientId, clientId)) {
throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer"
+ " only. Raise a separate request for each customer.");
}
}
if (fdIds.isEmpty()) {
return fdIds;
}
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type)
|| !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(),
"CURRENT".equals(type), amount);
}
return fdIds;
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -48,6 +48,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -56,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -84,6 +86,7 @@ public class ClerkWithdrawalRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
@@ -110,13 +113,28 @@ public class ClerkWithdrawalRequestApiResource {
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
// A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is
// paid from instead of a savings account; it is approved and disbursed exactly like any other slip.
final boolean isGl = "GL".equalsIgnoreCase(accountType);
final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root);
final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root);
final Long officeId = fromJsonHelper.extractLongNamed("officeId", root);
final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root);
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "Account number is required");
}
if (savingsAccountId == null) {
if (isGl) {
validateGlWithdrawalAccounts(glAccountId, contraGlId);
} else if (savingsAccountId == null) {
throw apiError(400, "Savings account id is required for a withdrawal request");
}
// Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip.
// Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account.
if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) {
throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from."
+ " Money leaves a fixed deposit only through its maturity or premature closure.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero");
}
@@ -124,7 +142,9 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(400, "Token number (TOK. NO.) is required");
}
validateTokenUniqueToday(tokNo, null);
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
if (!isGl) {
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
}
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -132,8 +152,9 @@ public class ClerkWithdrawalRequestApiResource {
INSERT INTO m_clerk_withdrawal_request
(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, txn_date,
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at,
gl_account_id, contra_gl_id, office_id, currency_code)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -142,7 +163,9 @@ public class ClerkWithdrawalRequestApiResource {
txnDate != null ? Date.valueOf(txnDate) : null,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now())
denominationData, activityLog, Timestamp.from(Instant.now()),
isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null,
isGl ? (currencyCode != null ? currencyCode : "INR") : null
);
// Globally unique voucher number from the central m_voucher register
@@ -198,7 +221,8 @@ public class ClerkWithdrawalRequestApiResource {
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
+ " denomination_data, activity_log, submitted_at, actioned_at,"
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
+ " cashier_id, disbursed_by, disbursed_at, voucher_no,"
+ " gl_account_id, contra_gl_id, office_id, currency_code"
+ " FROM m_clerk_withdrawal_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -252,7 +276,8 @@ public class ClerkWithdrawalRequestApiResource {
payment_type_name, description, tok_no, txn_date, status,
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray()
@@ -276,7 +301,8 @@ public class ClerkWithdrawalRequestApiResource {
txn_date, status, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -295,7 +321,8 @@ public class ClerkWithdrawalRequestApiResource {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
amount, payment_type_id, payment_type_name, description, tok_no, status,
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
ORDER BY submitted_at DESC LIMIT 1
@@ -455,13 +482,16 @@ public class ClerkWithdrawalRequestApiResource {
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
}
final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type"));
final Long savingsAccountId = record.get("savings_account_id") != null
? ((Number) record.get("savings_account_id")).longValue() : null;
// Checked again on approval, not only on submission: a request can sit in the queue for days,
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
// below refuses a dormant account anyway, but that failure would surface to the manager as a
// rejected transaction after the approval was recorded; this says why, before anything moves.
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
if (!isGlRequest) {
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
}
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date");
@@ -469,7 +499,11 @@ public class ClerkWithdrawalRequestApiResource {
final String description = (String) record.get("description");
String txnId = null;
if (savingsAccountId != null) {
if (isGlRequest) {
// Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal.
validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id")));
txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description);
} else if (savingsAccountId != null) {
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
@@ -477,14 +511,18 @@ public class ClerkWithdrawalRequestApiResource {
}
// Link voucher to the created savings txn and stamp voucher_no on it
if (txnId != null) {
if (txnId != null && !isGlRequest) {
voucherService.linkSavingsTransaction(
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
}
// The sides of the slip. A withdrawal is money leaving the member, so their account is
// debited and the till that pays it out is credited.
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
if (isGlRequest) {
addGlWithdrawalVoucherLines(record, amount);
} else {
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
}
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
@@ -689,8 +727,8 @@ public class ClerkWithdrawalRequestApiResource {
if (denomMap == null || denomMap.size() == 0) return;
final Object txnIdObj = record.get("txn_id");
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
if (txnId == null) return; // no savings txn linked — cannot record denomination
final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null;
if (txnId == null) return; // no txn linked — cannot record denomination
final Object saObj = record.get("savings_account_id");
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
@@ -793,6 +831,117 @@ public class ClerkWithdrawalRequestApiResource {
return rows.isEmpty() ? null : rows.get(0);
}
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
/**
* A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one
* Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under
* the id of its own journal entry in the cashier's position and closing report.
*/
private Long parseTxnKey(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return Long.valueOf(raw);
} catch (final NumberFormatException ignored) {
try {
final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw;
return Long.parseUnsignedLong(hex, 16);
} catch (final NumberFormatException ignored2) {
return null;
}
}
}
/** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the
* GL it is paid from must be one of the branch's mapped cash accounts. */
private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) {
if (glAccountId == null || contraGlId == null) {
throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request");
}
final List<Long> cashGlIds = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)",
Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue());
if (!cashGlIds.contains(contraGlId)) {
throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL.");
}
if (cashGlIds.contains(glAccountId)) {
throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip.");
}
}
/** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is
* switched off for manual postings). Returns the journal entry's transaction id. */
private String executeGlWithdrawalJournal(final Map<?, ?> record, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject debit = new JsonObject();
debit.addProperty("glAccountId", toLong(record.get("gl_account_id")));
debit.addProperty("amount", amount);
final JsonObject credit = new JsonObject();
credit.addProperty("glAccountId", toLong(record.get("contra_gl_id")));
credit.addProperty("amount", amount);
final JsonArray debits = new JsonArray();
debits.add(debit);
final JsonArray credits = new JsonArray();
credits.add(credit);
final Object office = record.get("office_id");
final Object currency = record.get("currency_code");
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L);
payload.addProperty("transactionDate", date);
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR");
if (note != null && !note.isBlank()) {
payload.addProperty("comments", note);
}
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
payload.add("credits", credits);
payload.add("debits", debits);
final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
// The request raised its own voucher at submission; the journal command must not raise a second.
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getTransactionId();
}
/** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */
private void addGlWithdrawalVoucherLines(final Map<?, ?> record, final BigDecimal amount) {
final String voucherNo = (String) record.get("voucher_no");
if (voucherNo == null || voucherNo.isBlank()) {
return;
}
try {
final Long glId = toLong(record.get("gl_account_id"));
final Long contraId = toLong(record.get("contra_gl_id"));
final Map<String, Object> gl = glAccountForVoucher(glId);
final Map<String, Object> contra = glAccountForVoucher(contraId);
voucherService.addLines(voucherNo, List.of(
VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount),
VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount)));
} catch (final RuntimeException e) {
LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e);
}
}
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
if (glAccountId == null) {
return Map.of();
}
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
.orElse(Map.of());
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
@@ -800,7 +949,8 @@ public class ClerkWithdrawalRequestApiResource {
txn_date, status, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id,
denomination_data, activity_log, submitted_at, actioned_at,
cashier_id, disbursed_by, disbursed_at, voucher_no
cashier_id, disbursed_by, disbursed_at, voucher_no,
gl_account_id, contra_gl_id, office_id, currency_code
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -109,6 +109,7 @@ public class FixedDepositAccountsApiResource {
private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService;
private final SqlValidator sqlValidator;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final org.apache.fineract.portfolio.savings.service.FixedDepositAccountPreviewService fixedDepositAccountPreviewService;
@GET
@Path("template")
@@ -315,6 +316,38 @@ public class FixedDepositAccountsApiResource {
return toApiJsonSerializer.serializeResult(result);
}
/**
* Fixed deposit quote while the account is being opened, for every FD type: takes the same body as Submit
* (clientId, productId, submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, isSeniorCitizen,
* locale, dateFormat ...) and returns the rate, maturity date and maturity amount Submit would store, plus the
* interest periods (FD Special: the monthly payout schedule). Nothing is saved (see FixedDepositAccountPreviewService).
*/
@POST
@Path("account-preview")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public String accountPreview(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
// Submit insists on a linked savings account - "linkAccountId" (FD Special's monthly payout account) and
// "linkedAccountId" (when the product's Deposit Account Link is on) - which the screen asks for after the quote.
// The quote does not depend on which account it is, only that the interest goes out monthly, so a stand-in id is
// used for whichever is not chosen yet. Both are only recorded on the account, never looked up, and the would-be
// account is never saved, so the stand-in goes nowhere.
if (parsed.isJsonObject()) {
for (final String linkParam : new String[] { DepositsApiConstants.linkedAccountParamName,
org.apache.fineract.portfolio.savings.SavingsApiConstants.linkedAccountIdParamName }) {
if (!parsed.getAsJsonObject().has(linkParam) || parsed.getAsJsonObject().get(linkParam).isJsonNull()) {
parsed.getAsJsonObject().addProperty(linkParam, -1L);
}
}
}
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
.from(parsed.toString(), parsed, this.fromJsonHelper, DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME, null,
null, null, null, null, null, null, null, null, null, null, null, null);
return this.toApiJsonSerializer.serializeResult(this.fixedDepositAccountPreviewService.preview(command));
}
private BigDecimal getActivationCharge(Long accountId) {
BigDecimal activationCharge = BigDecimal.ZERO;
Collection<SavingsAccountChargeData> savingCharges = this.savingsAccountChargeReadPlatformService
@@ -0,0 +1,119 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.data;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* FD Special is a non-cumulative product: its interest is paid out to the linked savings account every month, so the
* principal never grows and nothing compounds on it - in effect simple interest on the principal. Its product terms are
* therefore fixed, and the product screen locks them:
* <ul>
* <li>Interest Calculation Type ({@code interestCalculateSelector}) = Simple</li>
* <li>Interest Compounding Period = Monthly</li>
* <li>Interest Posting Period = Monthly</li>
* </ul>
* Checked here, on the server, against the product as it will be saved, so a request from Postman or any other client
* that sends different values is refused with a validation error rather than saved. Used on create, on update (which
* the maker-checker approval also goes through) and when a maker submits an edit for approval.
*/
@Component
@RequiredArgsConstructor
public class FdSpecialProductTermsValidator {
private static final String RESOURCE = "fixeddepositproduct";
private static final String SIMPLE = "Simple";
private final JdbcTemplate jdbcTemplate;
/** The product as assembled or updated, just before it is saved. */
public void validate(final FixedDepositProduct product) {
validate(product.getFdType(), product.getInterestCalculateSelector(), valueOf(product.interestCompoundingPeriodType()),
valueOf(product.interestPostingPeriodType()));
}
/**
* A maker's edit, before it waits for approval: each field as the edit leaves it - the submitted value where the
* request sends one, otherwise the product's current value.
*/
public void validateEdit(final FixedDepositProduct product, final JsonCommand command) {
final String fdType = command.parameterExists("fdType") ? command.stringValueOfParameterNamed("fdType") : product.getFdType();
final Integer selector = command.parameterExists("interestCalculateSelector")
? command.integerValueOfParameterNamed("interestCalculateSelector")
: product.getInterestCalculateSelector();
final Integer compounding = command.parameterExists("interestCompoundingPeriodType")
? command.integerValueOfParameterNamed("interestCompoundingPeriodType")
: valueOf(product.interestCompoundingPeriodType());
final Integer posting = command.parameterExists("interestPostingPeriodType")
? command.integerValueOfParameterNamed("interestPostingPeriodType")
: valueOf(product.interestPostingPeriodType());
validate(fdType, selector, compounding, posting);
}
private void validate(final String fdType, final Integer interestCalculateSelector, final Integer compoundingPeriodType,
final Integer postingPeriodType) {
if (!"SPECIAL".equalsIgnoreCase(fdType)) {
return;
}
final List<ApiParameterError> errors = new ArrayList<>();
final String calculationType = interestCalculateSelector == null ? null
: this.jdbcTemplate.queryForList("SELECT code_value FROM m_code_value WHERE id = ?", String.class, interestCalculateSelector)
.stream().findFirst().orElse(null);
if (calculationType == null || !SIMPLE.equalsIgnoreCase(calculationType.trim())) {
errors.add(ApiParameterError.parameterError("validation.msg." + RESOURCE + ".special.interestCalculateSelector.must.be.simple",
"FD Special pays its interest out every month, so nothing compounds: Interest Calculation Type must be Simple"
+ (calculationType == null ? "." : ", not " + calculationType + "."),
"interestCalculateSelector", interestCalculateSelector));
}
if (compoundingPeriodType == null || compoundingPeriodType != SavingsCompoundingInterestPeriodType.MONTHLY.getValue()) {
errors.add(ApiParameterError.parameterError(
"validation.msg." + RESOURCE + ".special.interestCompoundingPeriodType.must.be.monthly",
"FD Special pays its interest out every month: Interest Compounding Period must be Monthly.",
"interestCompoundingPeriodType", compoundingPeriodType));
}
if (postingPeriodType == null || postingPeriodType != SavingsPostingInterestPeriodType.MONTHLY.getValue()) {
errors.add(ApiParameterError.parameterError(
"validation.msg." + RESOURCE + ".special.interestPostingPeriodType.must.be.monthly",
"FD Special pays its interest out every month: Interest Posting Period must be Monthly.", "interestPostingPeriodType",
postingPeriodType));
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
private static Integer valueOf(final SavingsCompoundingInterestPeriodType type) {
return type == null ? null : type.getValue();
}
private static Integer valueOf(final SavingsPostingInterestPeriodType type) {
return type == null ? null : type.getValue();
}
}
@@ -392,6 +392,9 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate");
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
// An FD is funded once, with exactly its principal (see FixedDepositFundingValidator).
new FixedDepositFundingValidator(this.jdbcTemplate).validateFirstFunding(savingsId, transactionAmount);
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validatePreviousDayClosed();
@@ -422,6 +425,20 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Transactional
@Override
public void recalculateFixedDepositMaturity(final Long savingsId) {
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@Transactional
@Override
public CommandProcessingResult updateDepositAmountForRDAccount(Long savingsId, JsonCommand command) {
@@ -48,7 +48,6 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.event.business.domain.deposit.FixedDepositAccountCreateBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.deposit.RecurringDepositAccountCreateBusinessEvent;
@@ -184,16 +183,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, submittedBy,
DepositAccountType.FIXED_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Fixed Deposit account with the same product.",
"productId", account.productId());
}
}
// A client may hold any number of Fixed Deposits on the same product, even while an earlier one is
// still active, so there is deliberately no one-active-account-per-product check here.
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
@@ -269,16 +260,8 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(command,
submittedBy, DepositAccountType.RECURRING_DEPOSIT);
if (account.getClient() != null && account.productId() != null) {
long existingCount = this.savingAccountRepository.countActiveAccountsByClientAndProduct(
account.getClient().getId(), account.productId());
if (existingCount > 0) {
throw new PlatformDataIntegrityException(
"error.msg.savings.account.duplicate.product.for.client",
"Client already has an active Recurring Deposit account with the same product.",
"productId", account.productId());
}
}
// A client may hold any number of Recurring Deposits on the same product, even while an earlier one
// is still active, so there is deliberately no one-active-account-per-product check here.
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
@@ -0,0 +1,148 @@
/**
* 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.savings.service;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.domain.DepositAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Fixed deposit quote for the account-opening screen - every FD type - worked out by the account engine itself rather
* than by the screen or a separate calculator.
* <p>
* The would-be account is built exactly as Submit builds it ({@code submitFDApplication}): the same assembler (product
* defaults, the product's interest rate chart, the CBS base-rate card and senior-citizen rate), the same maturity
* calculation, the same CBS fields. Nothing is saved: it all runs in a transaction that is always rolled back. So the
* rate, maturity date and maturity amount quoted are exactly what Submit would store - including the product's posting
* periods (e.g. Quarterly posting closes each period at the calendar quarter end, from the financial-year start month),
* which a stand-alone formula that counts quarters from the deposit date gets wrong.
* <p>
* The interest periods come from {@link FixedDepositAccount#projectInterestSchedule}, the method the account's Monthly
* Interest Payout Schedule uses once it is open. For FD Special they are the monthly payouts; for a cumulative FD they
* are the periods interest is credited to the deposit and compounds.
* <p>
* The schedule assumes the deposit starts on the submitted date; once opened, the account's own schedule runs from its
* actual activation date.
*/
@Service
@RequiredArgsConstructor
public class FixedDepositAccountPreviewService {
private final PlatformSecurityContext context;
private final DepositAccountAssembler depositAccountAssembler;
private final SavingsAccountAssembler savingsAccountAssembler;
private final ConfigurationDomainService configurationDomainService;
private final PlatformTransactionManager transactionManager;
public Map<String, Object> preview(final JsonCommand command) {
final TransactionTemplate template = new TransactionTemplate(this.transactionManager);
return template.execute(status -> {
// Never keep anything the assembler touched: this is a quote, not an application.
status.setRollbackOnly();
return buildPreview(command);
});
}
private Map<String, Object> buildPreview(final JsonCommand command) {
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
// Same steps, in the same order, as DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl#submitFDApplication.
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command,
this.context.authenticatedUser(), DepositAccountType.FIXED_DEPOSIT);
final boolean special = "SPECIAL".equalsIgnoreCase(account.fdType());
account.updateMaturityDateAndAmountBeforeAccountActivation(MathContext.DECIMAL64, false,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
// Same MathContext and clipping as DepositAccountWritePlatformServiceJpaRepositoryImpl#retrieveProjectedInterestSchedule.
final RoundingMode rounding = MoneyHelper.getRoundingMode();
final List<PostingPeriod> postingPeriods = account.projectInterestSchedule(new MathContext(15, rounding),
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
final LocalDate maturityDate = account.maturityDate();
final LocalDate lastCalculableDate = maturityDate != null ? maturityDate.minusDays(1) : null;
final List<Map<String, Object>> periods = new ArrayList<>();
BigDecimal totalInterest = BigDecimal.ZERO;
int number = 1;
for (final PostingPeriod period : postingPeriods) {
final LocalDate fromDate = period.getPeriodInterval().startDate();
LocalDate toDate = period.getPeriodInterval().endDate();
if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) {
toDate = lastCalculableDate;
}
final BigDecimal interest = period.getInterestEarned().getAmount();
totalInterest = totalInterest.add(interest);
final Map<String, Object> row = new LinkedHashMap<>();
row.put("period", number++);
row.put("fromDate", fromDate.toString());
row.put("toDate", toDate.toString());
row.put("days", ChronoUnit.DAYS.between(fromDate, toDate) + 1);
row.put("interestAmount", interest);
periods.add(row);
}
final BigDecimal depositAmount = account.getDepositAmount();
final BigDecimal rate = account.getNominalAnnualInterestRate();
final Map<String, Object> result = new LinkedHashMap<>();
result.put("fdType", account.fdType());
result.put("depositAmount", depositAmount);
result.put("annualInterestRate", rate);
result.put("seniorCitizen", account.isSeniorCitizen());
result.put("interestPostingPeriodType", account.getInterestPostingPeriodType());
result.put("interestCalculationDaysInYearType", account.getInterestCalculationDaysInYearType());
result.put("startDate", periods.isEmpty() ? null : periods.get(0).get("fromDate"));
result.put("maturityDate", maturityDate == null ? null : maturityDate.toString());
result.put("numberOfPayouts", special ? periods.size() : 0);
// FD Special: one full month at the applied rate; a period pays for the days it actually covers (see periods).
result.put("fullMonthPayout", !special || rate == null ? null
: depositAmount.multiply(rate, MoneyHelper.getMathContext()).divide(BigDecimal.valueOf(1200), MoneyHelper.getMathContext())
.setScale(2, rounding));
// The account's own maturity amount, as Submit stores it: for FD Special the principal alone (the interest is paid
// out month by month), otherwise principal plus the compounded interest.
final BigDecimal maturityAmount = account.maturityAmount();
final BigDecimal interest = special ? totalInterest
: (maturityAmount == null ? null : maturityAmount.subtract(depositAmount));
result.put("totalInterest", interest);
result.put("maturityAmount", maturityAmount);
result.put("totalValueAtMaturity", interest == null ? null : depositAmount.add(interest));
result.put("periods", periods);
return result;
}
}
@@ -0,0 +1,238 @@
/**
* 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.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The rules a fixed deposit is funded under, shared by every path money can reach an FD by: the FD deposit
* command itself (cash at the counter, approved cashier deposit requests) and the clerk's Bank Transfer,
* Fund Transfer and Disbursement requests, which credit the account directly.
*
* <p>
* An FD opens with no money in it and is funded exactly once, with exactly its principal. A second credit
* would silently change the principal the maturity was agreed on; anything more goes into a new FD (several
* FDs on the same product are allowed). Money never leaves an FD through these paths either - only through
* its maturity or premature closure.
*
* <p>
* When the money comes from the customer's own Savings or Current account, that account must be able to pay
* it: active, not debit-frozen, out of any lock-in, belonging to the FD's client (or held jointly with them),
* and holding enough available balance once holds, liens, clearing holds and its minimum balance are set
* aside. The KYC debit restriction on that account is checked by the callers through KycRestrictionGuard.
*/
@Component
@RequiredArgsConstructor
public class FixedDepositFundingValidator {
/** m_savings_account.deposit_type_enum (Fineract DepositAccountType). */
public static final int DEPOSIT_TYPE_SAVINGS = 100;
public static final int DEPOSIT_TYPE_FIXED = 200;
private static final int STATUS_ACTIVE = 300;
private static final int TXN_TYPE_DEPOSIT = 1;
/** SavingsAccountSubStatusEnum.BLOCK (no debits or credits) and BLOCK_DEBIT. */
private static final int SUB_STATUS_BLOCK = 400;
private static final int SUB_STATUS_BLOCK_DEBIT = 600;
private final JdbcTemplate jdbcTemplate;
/** Whether this m_savings_account id is a fixed deposit (a CURRENT account id must not be passed here). */
public boolean isFixedDeposit(final Long savingsAccountId) {
if (savingsAccountId == null) {
return false;
}
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
Integer.class, savingsAccountId, DEPOSIT_TYPE_FIXED);
return count != null && count > 0;
}
/**
* The FD's first and only funding: the account must be an active, not yet matured fixed deposit with no
* money in it, and the amount must be exactly its principal.
*
* @return the FD's client id, for the source-account checks
*/
public Long validateFirstFunding(final Long fdAccountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT s.account_no, s.status_enum, s.deposit_type_enum, s.client_id, s.account_balance_derived,
t.deposit_amount, t.maturity_date
FROM m_savings_account s
LEFT JOIN m_deposit_account_term_and_preclosure t ON t.savings_account_id = s.id
WHERE s.id = ?
""", fdAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.account.not.found", "Fixed deposit account " + fdAccountId + " was not found.");
}
final Map<String, Object> fd = rows.get(0);
final String accountNo = (String) fd.get("account_no");
if (intOf(fd.get("deposit_type_enum")) != DEPOSIT_TYPE_FIXED) {
throw fail("error.msg.fd.funding.not.a.fixed.deposit", "Account " + accountNo + " is not a fixed deposit.");
}
if (intOf(fd.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.not.active",
"Fixed deposit " + accountNo + " is not active. Only an active fixed deposit can be funded.");
}
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final LocalDate maturityDate = fd.get("maturity_date") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (maturityDate != null && !businessDate.isBefore(maturityDate)) {
throw fail("error.msg.fd.funding.matured",
"Fixed deposit " + accountNo + " has reached its maturity date (" + maturityDate + ") and can no longer be funded.");
}
final Integer deposits = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE savings_account_id = ? AND transaction_type_enum = ?"
+ " AND is_reversed = false",
Integer.class, fdAccountId, TXN_TYPE_DEPOSIT);
if ((deposits != null && deposits > 0) || amountOf(fd.get("account_balance_derived")).signum() > 0) {
throw fail("error.msg.fd.funding.already.funded", "Fixed deposit " + accountNo
+ " is already funded. A fixed deposit is funded only once - open a new fixed deposit for more money.");
}
final BigDecimal principal = fd.get("deposit_amount") == null ? null : amountOf(fd.get("deposit_amount"));
if (principal == null || principal.signum() <= 0) {
throw fail("error.msg.fd.funding.principal.missing", "Fixed deposit " + accountNo + " has no deposit amount (principal) set.");
}
if (amount == null || amount.compareTo(principal) != 0) {
throw fail("error.msg.fd.funding.amount.mismatch", "Amount " + (amount == null ? "0" : amount.toPlainString())
+ " for fixed deposit " + accountNo + " must be exactly its principal " + principal.toPlainString() + " - not less, not more.");
}
return fd.get("client_id") == null ? null : ((Number) fd.get("client_id")).longValue();
}
/**
* The Savings or Current account an FD is being funded from.
*
* @param fdAccountId
* the FD being funded
* @param fdClientId
* its client, from {@link #validateFirstFunding}
* @param sourceAccountId
* m_savings_account id, or m_current_account id when {@code sourceIsCurrent}
*/
public void validateFundingSource(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final BigDecimal amount) {
final List<Map<String, Object>> rows = sourceIsCurrent ? jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, account_balance_derived, on_hold_funds_derived,
total_savings_amount_on_hold, min_required_balance, enforce_min_required_balance,
allow_overdraft, overdraft_limit, lockedin_until_date_derived
FROM m_current_account WHERE id = ?
""", sourceAccountId) : jdbcTemplate.queryForList("""
SELECT account_no, status_enum, sub_status_enum, client_id, deposit_type_enum, account_balance_derived,
on_hold_funds_derived, total_savings_amount_on_hold, total_savings_amount_on_hold_clearing,
min_required_balance, enforce_min_required_balance, lockedin_until_date_derived
FROM m_savings_account WHERE id = ?
""", sourceAccountId);
if (rows.isEmpty()) {
throw fail("error.msg.fd.funding.source.not.found", "Source account " + sourceAccountId + " was not found.");
}
final Map<String, Object> src = rows.get(0);
final String accountNo = (String) src.get("account_no");
if (!sourceIsCurrent && intOf(src.get("deposit_type_enum")) != DEPOSIT_TYPE_SAVINGS) {
throw fail("error.msg.fd.funding.source.type",
"Account " + accountNo + " cannot fund a fixed deposit - only a Savings or Current account can.");
}
if (intOf(src.get("status_enum")) != STATUS_ACTIVE) {
throw fail("error.msg.fd.funding.source.not.active", "Source account " + accountNo + " is not active.");
}
final int subStatus = intOf(src.get("sub_status_enum"));
if (subStatus == SUB_STATUS_BLOCK || subStatus == SUB_STATUS_BLOCK_DEBIT) {
throw fail("error.msg.fd.funding.source.debit.frozen", "Source account " + accountNo + " is blocked for debits (debit freeze).");
}
final LocalDate lockedUntil = src.get("lockedin_until_date_derived") instanceof java.sql.Date d ? d.toLocalDate() : null;
if (lockedUntil != null && DateUtils.getBusinessLocalDate().isBefore(lockedUntil)) {
throw fail("error.msg.fd.funding.source.locked.in", "Source account " + accountNo + " is locked-in until " + lockedUntil + ".");
}
final Long sourceClientId = src.get("client_id") == null ? null : ((Number) src.get("client_id")).longValue();
if (!sameClientOrJointHolder(fdAccountId, fdClientId, sourceAccountId, sourceIsCurrent, sourceClientId)) {
throw fail("error.msg.fd.funding.source.other.client", "Source account " + accountNo
+ " does not belong to the fixed deposit's customer. Fund an FD only from the customer's own account"
+ " or one they hold jointly.");
}
BigDecimal available = amountOf(src.get("account_balance_derived")).subtract(amountOf(src.get("on_hold_funds_derived")))
.subtract(amountOf(src.get("total_savings_amount_on_hold")));
if (!sourceIsCurrent) {
available = available.subtract(amountOf(src.get("total_savings_amount_on_hold_clearing")));
}
if (Boolean.TRUE.equals(src.get("enforce_min_required_balance"))) {
available = available.subtract(amountOf(src.get("min_required_balance")));
}
if (sourceIsCurrent && Boolean.TRUE.equals(src.get("allow_overdraft"))) {
available = available.add(amountOf(src.get("overdraft_limit")));
}
if (amount == null || available.compareTo(amount) < 0) {
throw fail("error.msg.fd.funding.source.insufficient", "Source account " + accountNo + " has an available balance of "
+ available.max(BigDecimal.ZERO).toPlainString() + " (after holds, lien and minimum balance), less than "
+ (amount == null ? "0" : amount.toPlainString()) + ".");
}
}
private boolean sameClientOrJointHolder(final Long fdAccountId, final Long fdClientId, final Long sourceAccountId,
final boolean sourceIsCurrent, final Long sourceClientId) {
if (fdClientId == null) {
return false;
}
if (Objects.equals(fdClientId, sourceClientId)) {
return true;
}
// The FD's customer is a joint holder on the source account ...
final Integer onSource = sourceIsCurrent
? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_current_account_joint_holder WHERE current_account_id = ? AND client_id = ?",
Integer.class, sourceAccountId, fdClientId)
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
sourceAccountId, fdClientId);
if (onSource != null && onSource > 0) {
return true;
}
// ... or the source account's customer is a joint holder on the FD.
if (sourceClientId == null) {
return false;
}
final Integer onFd = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_joint_holders WHERE savings_account_id = ? AND client_id = ?", Integer.class,
fdAccountId, sourceClientId);
return onFd != null && onFd > 0;
}
private static int intOf(final Object value) {
return value instanceof Number n ? n.intValue() : -1;
}
private static BigDecimal amountOf(final Object value) {
return value instanceof BigDecimal b ? b : value instanceof Number n ? new BigDecimal(n.toString()) : BigDecimal.ZERO;
}
private static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -89,6 +89,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
private final FromJsonHelper fromApiJsonHelper;
private final CommandSourceRepository commandSourceRepository;
private final org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator;
@Transactional
@Override
@@ -98,6 +99,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
this.fromApiJsonDataValidator.validateForFixedDepositCreate(command.json());
final FixedDepositProduct product = this.depositProductAssembler.assembleFixedDepositProduct(command);
// FD Special: Simple interest, monthly compounding and posting - refused here whatever client sent it.
this.fdSpecialProductTermsValidator.validate(product);
this.fixedDepositProductRepository.saveAndFlush(product);
@@ -136,6 +139,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
product.setHelpers(this.chartAssembler);
final Map<String, Object> changes = product.update(command);
// The product as this update leaves it (also the path an approved maker-checker edit takes).
this.fdSpecialProductTermsValidator.validate(product);
if (changes.containsKey(chargesParamName)) {
final Set<Charge> savingsProductCharges = this.depositProductAssembler.assembleListOfSavingsProductCharges(command,
@@ -197,6 +202,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
}
this.fromApiJsonDataValidator.validateForFixedDepositUpdate(command.json());
// Refused before it waits for a checker, not only when the checker approves it.
this.fdSpecialProductTermsValidator.validateEdit(product, command);
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_FD, productId, maker);
@@ -366,11 +366,12 @@ public class SavingsConfiguration {
ProductPendingEditChargeRepository pendingEditChargeRepository,
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
FromJsonHelper fromApiJsonHelper,
CommandSourceRepository commandSourceRepository) {
CommandSourceRepository commandSourceRepository,
org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator) {
return new FixedDepositProductWritePlatformServiceJpaRepositoryImpl(context, fixedDepositProductRepository,
fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler,
productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository,
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, fdSpecialProductTermsValidator);
}
@Bean
@@ -0,0 +1,623 @@
/**
* 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.voucher.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 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.NotFoundException;
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.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Corrects a cash deposit that went to the wrong savings account, without ever "undoing" it behind the
* cashier's back.
*
* <ol>
* <li>The cashier picks the day's voucher and asks to <b>unpass</b> it (UNPASS_PENDING). Nothing moves.</li>
* <li>A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply
* waiting for its correction entry.</li>
* <li>The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number
* (CORRECTION_PENDING).</li>
* <li>A manager approves the correction (COMPLETED). Only now do accounts change: the original deposit is
* reversed on the wrong account and the same cash is deposited into the right one, under a new voucher; the
* original voucher is revoked and the cashier's denominations follow the deposit.</li>
* </ol>
*
* The balance of the wrong account is checked twice, when the cashier enters the correction and again when it is
* approved, because the customer may have drawn the money in between.
*/
@Path("/v1/vouchers/corrections")
@Component
@Tag(name = "Voucher Corrections", description = "Unpass a wrongly-posted cash deposit voucher and re-post it to the right account.")
@RequiredArgsConstructor
public class VoucherCorrectionApiResource {
public static final String REF_VOUCHER_CORRECTION = "VOUCHER_CORRECTION";
private static final String UNPASS_PENDING = "UNPASS_PENDING";
private static final String UNPASSED = "UNPASSED";
private static final String UNPASS_REJECTED = "UNPASS_REJECTED";
private static final String CORRECTION_PENDING = "CORRECTION_PENDING";
private static final String COMPLETED = "COMPLETED";
private static final String CLOSED = "CLOSED";
private static final int SAVINGS_STATUS_ACTIVE = 300;
private static final int DEPOSIT_TYPE_SAVINGS = 100;
private static final DateTimeFormatter VOUCHER_DAY = DateTimeFormatter.ofPattern("ddMMyyyy");
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final KycRestrictionGuard kycRestrictionGuard;
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── Today's vouchers the cashier may unpass ──────────────────────────────────
/** Today's cash deposit vouchers on a savings account that have not been reversed, revoked or taken up already. */
@GET
@Path("eligible")
@Produces(MediaType.APPLICATION_JSON)
public String eligible(@QueryParam("search") final String search) {
requireUnpasser(this.context.authenticatedUser());
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
final List<Object> params = new ArrayList<>();
params.add("%/" + day + "/%");
final StringBuilder sql = new StringBuilder(ELIGIBLE_SQL);
if (search != null && !search.isBlank()) {
sql.append(" AND (v.voucher_no ILIKE ? OR sa.account_no ILIKE ? OR c.display_name ILIKE ?)");
final String like = "%" + search.trim() + "%";
params.add(like);
params.add(like);
params.add(like);
}
sql.append(" ORDER BY v.id DESC");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
}
private static final String ELIGIBLE_SQL = """
SELECT v.voucher_no AS "voucherNo", v.amount, v.created_at AS "createdAt",
v.created_by_username AS "cashier", v.savings_txn_id AS "txnId",
t.savings_account_id AS "savingsAccountId", sa.account_no AS "accountNo",
c.display_name AS "clientName", pt.value AS "paymentType"
FROM m_voucher v
JOIN m_savings_account_transaction t ON t.id = v.savings_txn_id
JOIN m_savings_account sa ON sa.id = t.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE v.voucher_type = 'DEPOSIT'
AND COALESCE(v.reference_type, '') <> 'VOUCHER_CORRECTION'
AND v.voucher_no LIKE ?
AND t.is_reversed = false
AND t.transaction_type_enum = 1
AND pt.is_cash_payment = true
AND sa.deposit_type_enum = 100
AND NOT EXISTS (SELECT 1 FROM m_voucher_correction vc
WHERE vc.original_voucher_no = v.voucher_no
AND vc.status NOT IN ('UNPASS_REJECTED'))
""";
// ── Step 1: cashier unpasses a voucher ───────────────────────────────────────
@POST
@Path("unpass")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String unpass(final String body) {
final AppUser user = this.context.authenticatedUser();
requireUnpasser(user);
final JsonElement root = fromJsonHelper.parse(body);
final String voucherNo = fromJsonHelper.extractStringNamed("voucherNo", root);
final String reason = fromJsonHelper.extractStringNamed("reason", root);
if (voucherNo == null || voucherNo.isBlank()) {
throw apiError(400, "Voucher number is required");
}
if (reason == null || reason.isBlank()) {
throw apiError(400, "Reason is required");
}
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(ELIGIBLE_SQL + " AND v.voucher_no = ?", "%/" + day + "/%",
voucherNo.trim());
if (rows.isEmpty()) {
throw apiError(409, "Voucher " + voucherNo
+ " cannot be unpassed: only today's cash deposit vouchers on a savings account that are still active qualify.");
}
final Map<String, Object> v = rows.get(0);
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_voucher_correction
(original_voucher_no, original_txn_id, wrong_savings_account_id, wrong_account_no, wrong_client_name,
amount, payment_type_id, txn_date, status, reason, unpass_by_id, unpass_by_name, unpass_at, activity_log)
VALUES (?,?,?,?,?,?,(SELECT pd.payment_type_id FROM m_savings_account_transaction t
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id WHERE t.id = ?),
(SELECT transaction_date FROM m_savings_account_transaction WHERE id = ?),
?,?,?,?,?,?)
RETURNING id
""", Long.class, voucherNo.trim(), v.get("txnId"), v.get("savingsAccountId"), v.get("accountNo"), v.get("clientName"),
v.get("amount"), v.get("txnId"), v.get("txnId"), UNPASS_PENDING, reason.trim(), user.getId(), user.getDisplayName(),
Timestamp.from(Instant.now()), log(null, "UNPASS REQUESTED", user, reason.trim()));
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_PENDING));
}
// ── Lists ────────────────────────────────────────────────────────────────────
/**
* Corrections by status. {@code status} may be a comma-separated list. The approval page asks for
* UNPASS_PENDING and CORRECTION_PENDING, the cashier's page for UNPASSED.
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(@QueryParam("status") final String status, @QueryParam("mine") @DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_voucher_correction WHERE 1=1");
final List<Object> params = new ArrayList<>();
if (status != null && !status.isBlank()) {
final String[] statuses = status.split(",");
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
for (final String s : statuses) {
params.add(s.trim().toUpperCase());
}
}
if (mine) {
sql.append(" AND (unpass_by_id = ? OR correction_by_id = ?)");
params.add(user.getId());
params.add(user.getId());
}
sql.append(" ORDER BY id DESC LIMIT 200");
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
}
@GET
@Path("{id}")
@Produces(MediaType.APPLICATION_JSON)
public String get(@PathParam("id") final Long id) {
this.context.authenticatedUser();
return gson.toJson(load(id));
}
// ── Step 2: manager approves / rejects the unpass ────────────────────────────
@POST
@Path("{id}/unpass/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approveUnpass(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, UNPASS_PENDING, "approved");
requireNotMaker(user, r.get("unpass_by_id"), "unpass request");
final int updated = jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, unpass_approved_by_id = ?, unpass_approved_by_name = ?,
unpass_approved_at = ?, activity_log = ?
WHERE id = ? AND status = ?
""", UNPASSED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
log((String) r.get("activity_log"), "UNPASS APPROVED", user, null), id, UNPASS_PENDING);
if (updated == 0) {
throw apiError(409, "This request was already actioned by another manager.");
}
// Deliberately no account movement here: the deposit stays exactly where it is until the correction is approved.
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
}
@POST
@Path("{id}/unpass/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String rejectUnpass(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, UNPASS_PENDING, "rejected");
final String reason = requireReason(body);
jdbcTemplate.update("UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ?", UNPASS_REJECTED,
reason, log((String) r.get("activity_log"), "UNPASS REJECTED", user, reason), id);
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_REJECTED));
}
// ── Step 3: cashier enters the correct account ───────────────────────────────
/** Resolves the account number the cashier typed: name, balance and whether it can receive the deposit. */
@GET
@Path("account-lookup")
@Produces(MediaType.APPLICATION_JSON)
public String accountLookup(@QueryParam("accountNo") final String accountNo) {
this.context.authenticatedUser();
final Map<String, Object> account = findSavingsAccount(accountNo);
if (account == null) {
throw apiError(404, "No savings account found for '" + accountNo + "'.");
}
return gson.toJson(account);
}
@POST
@Path("{id}/correction")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String submitCorrection(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
// The corrected entry is the cashier's own: a deposit is taken at the counter, so only a cashier can re-enter it.
if (cashierRoleHelper.getCurrentCashierId() == null) {
throw apiError(403, "Only a cashier can enter the correction for a voucher. Your login is not linked to a cashier.");
}
final Map<String, Object> r = load(id);
requireStatus(r, UNPASSED, "corrected");
final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body));
final Map<String, Object> correct = validateCorrectAccount(r, accountNo);
checkWrongAccountBalance(r);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, correct_savings_account_id = ?, correct_account_no = ?,
correct_client_id = ?, correct_client_name = ?, correction_by_id = ?, correction_by_name = ?,
correction_at = ?, rejection_reason = NULL, activity_log = ?
WHERE id = ? AND status = ?
""", CORRECTION_PENDING, correct.get("id"), correct.get("accountNo"), correct.get("clientId"), correct.get("clientName"),
user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
log((String) r.get("activity_log"), "CORRECTION SUBMITTED", user, "to " + correct.get("accountNo")), id, UNPASSED);
return gson.toJson(Map.of("resourceId", id, "status", CORRECTION_PENDING));
}
// ── Step 4: manager approves / rejects the correction ────────────────────────
@POST
@Path("{id}/correction/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String approveCorrection(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, CORRECTION_PENDING, "approved");
requireNotMaker(user, r.get("correction_by_id"), "correction");
// Both sides are checked again: a lot can happen between the cashier saving and the manager approving.
validateCorrectAccount(r, (String) r.get("correct_account_no"));
checkWrongAccountBalance(r);
final Long wrongId = toLong(r.get("wrong_savings_account_id"));
final Long correctId = toLong(r.get("correct_savings_account_id"));
final Long origTxnId = toLong(r.get("original_txn_id"));
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
final String origVoucher = (String) r.get("original_voucher_no");
final Integer stillLive = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE id = ? AND savings_account_id = ? AND is_reversed = false",
Integer.class, origTxnId, wrongId);
if (stillLive == null || stillLive == 0) {
throw apiError(409, "The original deposit has already been reversed; this voucher can no longer be corrected.");
}
// 1. Take the deposit off the wrong account, 2. put the same cash into the right one.
final JsonObject undoPayload = new JsonObject();
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(
new CommandWrapperBuilder().undoSavingsAccountTransaction(wrongId, origTxnId).withJson(undoPayload.toString()).build()));
final String newTxnId = depositInto(correctId, amount, r);
final Long newTxnKey = Long.valueOf(newTxnId);
// 3. A new voucher for the corrected entry, in the name of the cashier who made it, with the same till side.
final String newVoucher = voucherService.createVoucher(VoucherService.TYPE_DEPOSIT, REF_VOUCHER_CORRECTION, id, amount, user);
jdbcTemplate.update("UPDATE m_voucher SET created_by = ?, created_by_username = (SELECT username FROM m_appuser WHERE id = ?) WHERE voucher_no = ?",
r.get("correction_by_id"), r.get("correction_by_id"), newVoucher);
voucherService.linkSavingsTransaction(REF_VOUCHER_CORRECTION, id, newTxnKey);
addNewVoucherLines(newVoucher, origVoucher, correctId, (String) r.get("correct_client_name"), (String) r.get("correct_account_no"),
amount);
// 4. The original voucher is not touched. That it is revoked, and by which voucher, is recorded on this
// correction (original_voucher_no / new_voucher_no) and read from there by the registers.
// 5. The notes the cashier took in follow the deposit to its new transaction and account.
jdbcTemplate.update("""
UPDATE m_savings_txn_denomination SET txn_id = ?, savings_account_id = ?, account_no = ?, client_id = ?
WHERE txn_id = ? AND savings_account_id = ?
""", newTxnKey, correctId, r.get("correct_account_no"), r.get("correct_client_id"), origTxnId, wrongId);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, correction_approved_by_id = ?, correction_approved_by_name = ?,
correction_approved_at = ?, new_voucher_no = ?, new_txn_id = ?, activity_log = ?
WHERE id = ? AND status = ?
""", COMPLETED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), newVoucher, newTxnKey,
log((String) r.get("activity_log"), "CORRECTION APPROVED", user, "new voucher " + newVoucher), id, CORRECTION_PENDING);
return gson.toJson(Map.of("resourceId", id, "status", COMPLETED, "newVoucherNo", newVoucher));
}
/** A rejected correction goes back to the cashier as UNPASSED, to enter the account again. */
@POST
@Path("{id}/correction/reject")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String rejectCorrection(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
validateCheckerRole(user);
final Map<String, Object> r = load(id);
requireStatus(r, CORRECTION_PENDING, "rejected");
final String reason = requireReason(body);
jdbcTemplate.update("""
UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, correct_savings_account_id = NULL,
correct_account_no = NULL, correct_client_id = NULL, correct_client_name = NULL, activity_log = ?
WHERE id = ?
""", UNPASSED, reason, log((String) r.get("activity_log"), "CORRECTION REJECTED", user, reason), id);
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
}
/**
* The cashier gives up the correction. Used when the wrong account no longer holds the money (the customer has
* already drawn it), so it cannot be taken back: the cashier then puts the amount into the right account from
* their own pocket as a fresh deposit, and this request is closed. No account moves here, and the original voucher
* stays as it was.
*/
@POST
@Path("{id}/close")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String close(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
if (cashierRoleHelper.getCurrentCashierId() == null) {
throw apiError(403, "Only a cashier can close a voucher correction.");
}
final Map<String, Object> r = load(id);
requireStatus(r, UNPASSED, "closed");
final String reason = requireReason(body);
final int updated = jdbcTemplate.update(
"UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ? AND status = ?", CLOSED,
reason, log((String) r.get("activity_log"), "CLOSED BY CASHIER", user, reason), id, UNPASSED);
if (updated == 0) {
throw apiError(409, "This request was already actioned.");
}
return gson.toJson(Map.of("resourceId", id, "status", CLOSED));
}
// ── For the passbook and transaction list of the wrong account ───────────────
/** Transactions of one account that were removed by a completed correction, so its screens can leave them out. */
@GET
@Path("voided-transactions")
@Produces(MediaType.APPLICATION_JSON)
public String voidedTransactions(@QueryParam("savingsAccountId") final Long savingsAccountId) {
this.context.authenticatedUser();
final JsonArray ids = new JsonArray();
jdbcTemplate.queryForList(
"SELECT original_txn_id FROM m_voucher_correction WHERE status = ? AND wrong_savings_account_id = ?", Long.class, COMPLETED,
savingsAccountId).forEach(ids::add);
return gson.toJson(ids);
}
// ── helpers ──────────────────────────────────────────────────────────────────
/** Unpassing a voucher is for the back office: an administrator or a clerk. The cashier does not unpass, only re-enters. */
private void requireUnpasser(final AppUser user) {
final boolean admin = user.hasAnyPermission("ALL_FUNCTIONS");
final boolean clerk = user.getRoles().stream().anyMatch(role -> role.getName() != null && "clerk".equals(role.getName().toLowerCase().trim()));
if (!admin && !clerk) {
throw apiError(403, "Only an administrator or a clerk can unpass a voucher.");
}
}
private Map<String, Object> validateCorrectAccount(final Map<String, Object> r, final String accountNo) {
if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "The correct account number is required");
}
final Map<String, Object> correct = findSavingsAccount(accountNo);
if (correct == null) {
throw apiError(404, "No savings account found for '" + accountNo + "'.");
}
if (toLong(correct.get("id")).equals(toLong(r.get("wrong_savings_account_id")))) {
throw apiError(400, "The correct account is the same account the deposit was posted to.");
}
if (!Boolean.TRUE.equals(correct.get("canReceive"))) {
throw apiError(409, "Account " + correct.get("accountNo") + " is not active, so it cannot receive this deposit.");
}
final Object wrongCurrency = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class,
r.get("wrong_savings_account_id")).stream().findFirst().orElse(null);
if (wrongCurrency != null && !wrongCurrency.equals(correct.get("currencyCode"))) {
throw apiError(409, "The correct account is in a different currency.");
}
kycRestrictionGuard.check(toLong(correct.get("id")), KycRestrictionGuard.Operation.CREDIT);
return correct;
}
/** The wrong account must still hold at least this deposit, or taking it back would overdraw it. */
private void checkWrongAccountBalance(final Map<String, Object> r) {
final BigDecimal balance = jdbcTemplate.queryForObject("SELECT account_balance_derived FROM m_savings_account WHERE id = ?",
BigDecimal.class, r.get("wrong_savings_account_id"));
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
if (balance == null || balance.compareTo(amount) < 0) {
throw apiError(409, "Account " + r.get("wrong_account_no") + " no longer holds ₹" + amount + " (balance ₹"
+ (balance == null ? "0" : balance) + "), so the deposit cannot be taken back. The customer may have used the money.");
}
}
private Map<String, Object> findSavingsAccount(final String accountNo) {
if (accountNo == null || accountNo.isBlank()) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no AS "accountNo", sa.client_id AS "clientId", c.display_name AS "clientName",
sa.account_balance_derived AS "balance", sa.currency_code AS "currencyCode",
(sa.status_enum = ? AND sa.deposit_type_enum = ?) AS "canReceive"
FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.account_no = ?
""", SAVINGS_STATUS_ACTIVE, DEPOSIT_TYPE_SAVINGS, accountNo.trim());
return rows.isEmpty() ? null : rows.get(0);
}
private String depositInto(final Long savingsAccountId, final BigDecimal amount, final Map<String, Object> r) {
final Object txnDate = r.get("txn_date");
final String date = txnDate == null ? DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"))
: LocalDate.parse(txnDate.toString().substring(0, 10)).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (r.get("payment_type_id") != null) {
payload.addProperty("paymentTypeId", ((Number) r.get("payment_type_id")).longValue());
}
payload.addProperty("note", "Correction of voucher " + r.get("original_voucher_no"));
final CommandWrapper command = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(command));
if (result.getResourceId() == null) {
throw apiError(500, "The corrected deposit could not be posted.");
}
return result.getResourceId().toString();
}
/** The new voucher keeps the original's till side and names the right account on the credit side. */
private void addNewVoucherLines(final String newVoucher, final String origVoucher, final Long correctId, final String clientName,
final String accountNo, final BigDecimal amount) {
try {
final List<VoucherLine> lines = new ArrayList<>();
for (final Map<String, Object> l : jdbcTemplate.queryForList(
"SELECT gl_account_id, party_name, account_no FROM m_voucher_line WHERE voucher_no = ? AND entry_type = 'DR' ORDER BY line_no",
origVoucher)) {
lines.add(VoucherLine.debitGl(toLong(l.get("gl_account_id")), (String) l.get("party_name"), (String) l.get("account_no"),
amount));
}
if (lines.isEmpty()) {
lines.add(VoucherLine.debitGl(null, "Cash in Hand", null, amount));
}
lines.add(VoucherLine.creditSavings(correctId, clientName, accountNo, amount));
voucherService.addLines(newVoucher, lines);
} catch (final RuntimeException e) {
// The deposit is already posted; a voucher without its sides is better than undoing a correct posting.
org.slf4j.LoggerFactory.getLogger(VoucherCorrectionApiResource.class)
.error("Could not record the sides of correction voucher {}", newVoucher, e);
}
}
private Map<String, Object> load(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_voucher_correction WHERE id = ?", id);
if (rows.isEmpty()) {
throw new NotFoundException("Voucher correction not found: " + id);
}
return rows.get(0);
}
private void requireStatus(final Map<String, Object> r, final String expected, final String action) {
if (!expected.equals(r.get("status"))) {
throw apiError(409, "Only " + expected + " requests can be " + action + ". Current status: " + r.get("status"));
}
}
private void requireNotMaker(final AppUser user, final Object makerId, final String what) {
if (makerId != null && toLong(makerId).equals(user.getId())) {
throw apiError(403, "Maker-checker: you cannot approve a " + what + " you raised yourself. Ask another manager.");
}
}
private String requireReason(final String body) {
final String reason = fromJsonHelper.extractStringNamed("reason", fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}"));
if (reason == null || reason.isBlank()) {
throw apiError(400, "Reason is required");
}
return reason.trim();
}
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject voucher corrections.");
}
}
private static Long toLong(final Object value) {
return value instanceof Number n ? n.longValue() : null;
}
private String log(final String existing, final String action, final AppUser user, final String note) {
final JsonArray entries = existing == null || existing.isBlank() ? new JsonArray() : gson.fromJson(existing, JsonArray.class);
final JsonObject e = new JsonObject();
e.addProperty("action", action);
e.addProperty("by", user.getUsername());
e.addProperty("at", Instant.now().toString());
if (note != null) {
e.addProperty("note", note);
}
entries.add(e);
return gson.toJson(entries);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", message);
errors.add(err);
body.add("errors", errors);
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -628,9 +628,15 @@ public class VoucherVerificationApiResource {
BigDecimal total = BigDecimal.ZERO;
final List<Map<String, Object>> out = new java.util.ArrayList<>();
final Map<String, Map<String, Object>> marks = voucherService
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
for (final Map<String, Object> r : rows) {
final BigDecimal amount = (BigDecimal) r.get("amount");
if (amount != null) {
final Map<String, Object> mark = marks.get((String) r.get("voucher_no"));
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
// A revoked voucher stays on the register, marked, but its money is on the voucher that replaced it, so it
// is left out of the total rather than counted twice.
if (amount != null && !revoked) {
total = total.add(amount);
}
@@ -638,6 +644,7 @@ public class VoucherVerificationApiResource {
m.put("voucherNo", r.get("voucher_no"));
m.put("voucherType", r.get("voucher_type"));
m.put("amount", amount);
putVoucherMark(m, mark);
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
// The day the posting belongs to, and whether that differs from the day it was written.
// A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's
@@ -847,6 +854,8 @@ public class VoucherVerificationApiResource {
int raisedCount = 0;
int verifiedCount = 0;
final List<Map<String, Object>> out = new java.util.ArrayList<>();
final Map<String, Map<String, Object>> myWorkMarks = voucherService
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
for (final Map<String, Object> r : rows) {
final BigDecimal amount = (BigDecimal) r.get("amount");
@@ -869,7 +878,10 @@ public class VoucherVerificationApiResource {
final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched
? "JV" : rawSide;
if (amount != null) {
final Map<String, Object> mark = myWorkMarks.get(voucherNo);
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
// A revoked voucher is shown, marked, but its money is on the voucher that replaced it, so it is not counted.
if (amount != null && !revoked) {
if ("CR".equals(side)) {
receivedTotal = receivedTotal.add(amount);
} else if ("DR".equals(side)) {
@@ -897,6 +909,7 @@ public class VoucherVerificationApiResource {
m.put("raisedBy", r.get("created_by_username"));
m.put("branchName", r.get("branch_name"));
m.put("verificationStatus", r.get("verification_status"));
putVoucherMark(m, mark);
m.put("verifiedByName", r.get("verified_by_name"));
m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString());
m.put("debitParty", r.get("debit_party"));
@@ -968,6 +981,14 @@ public class VoucherVerificationApiResource {
* returns an empty list rather than an error: the voucher is real, its posting simply was not
* captured at the time, and reporting it as missing would be wrong.
*/
/** Adds where the voucher stands (active, revoked, under correction, corrected) to a register row. */
private void putVoucherMark(final Map<String, Object> row, final Map<String, Object> mark) {
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
}
@GET
@Path("detail")
public String detail(@QueryParam("voucherNo") final String voucherNo) {
@@ -1032,6 +1053,7 @@ public class VoucherVerificationApiResource {
out.put("createdBy", h.get("created_by_username"));
out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString());
out.put("verificationStatus", h.get("verification_status"));
putVoucherMark(out, voucherService.voucherMarks(List.of(voucherNo)).get(voucherNo));
out.put("lines", lines);
out.put("totalDebit", totalDebit);
out.put("totalCredit", totalCredit);
@@ -595,6 +595,66 @@ public class VoucherService {
""", voucherNo);
}
/**
* Where each of these vouchers stands, for the registers that list vouchers.
*
* A voucher is ACTIVE until a correction touches it. REVOKED means its deposit was moved to another account and a
* new voucher replaced it (revokedByVoucherNo). A voucher with a correction under way reads UNPASS_PENDING,
* UNPASSED or CORRECTION_PENDING. The replacement itself reads CORRECTED, with the voucher it replaced in
* replacesVoucherNo. A register adds these to its rows and leaves a REVOKED voucher out of its totals, since its
* money now sits on the replacement.
*/
public Map<String, Map<String, Object>> voucherMarks(final java.util.Collection<String> voucherNos) {
final Map<String, Map<String, Object>> marks = new java.util.HashMap<>();
final List<String> nos = voucherNos == null ? List.of()
: voucherNos.stream().filter(n -> n != null && !n.isBlank()).distinct().toList();
if (nos.isEmpty()) {
return marks;
}
final String in = String.join(",", java.util.Collections.nCopies(nos.size(), "?"));
// Read from the correction register alone: the voucher itself carries no status, so nothing about a correction
// is ever written onto m_voucher and the voucher rows stay exactly as they were raised.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT v.voucher_no,"
+ " (SELECT vc.new_voucher_no FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
+ " ORDER BY vc.id DESC LIMIT 1) AS revoked_by_voucher_no,"
+ " (SELECT vc.original_voucher_no FROM m_voucher_correction vc"
+ " WHERE vc.new_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
+ " ORDER BY vc.id DESC LIMIT 1) AS replaces_voucher_no,"
+ " (SELECT vc.status FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no"
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED')"
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_status,"
// The reason behind it: why a closed request was closed, otherwise why the voucher was unpassed.
+ " (SELECT CASE WHEN vc.status = 'CLOSED' THEN vc.rejection_reason ELSE vc.reason END"
+ " FROM m_voucher_correction vc"
+ " WHERE vc.original_voucher_no = v.voucher_no"
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED', 'COMPLETED')"
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_note"
+ " FROM m_voucher v WHERE v.voucher_no IN (" + in + ")",
nos.toArray());
for (final Map<String, Object> r : rows) {
final String status;
if (r.get("revoked_by_voucher_no") != null) {
status = "REVOKED";
} else if (r.get("correction_status") != null) {
status = (String) r.get("correction_status");
} else if (r.get("replaces_voucher_no") != null) {
status = "CORRECTED";
} else {
status = "ACTIVE";
}
final Map<String, Object> m = new java.util.LinkedHashMap<>();
m.put("voucherStatus", status);
m.put("revokedByVoucherNo", r.get("revoked_by_voucher_no"));
m.put("replacesVoucherNo", r.get("replaces_voucher_no"));
m.put("correctionNote", r.get("correction_note"));
marks.put((String) r.get("voucher_no"), m);
}
return marks;
}
/**
* Looks up the voucher for the given request and returns its voucher_no,
* or null if none exists (e.g. records created before vouchers existed).
@@ -821,3 +821,69 @@ fineract.npci.ifsc-prefix=${FINERACT_NPCI_IFSC_PREFIX:CBSH}
# will fail and NPCI gracefully falls back to the poll model (GET /clearing/inward/{bankCode}).
fineract.npci.inward-callback-url=${FINERACT_NPCI_INWARD_CALLBACK_URL:https://localhost:8443/fineract-provider/api/v1/npci/inward-push}
fineract.npci.timeout-ms=${FINERACT_NPCI_TIMEOUT_MS:10000}
# ── I4C (NCRP-CFCFRMS) API Integration ──────────────────────────────────────
# I4C-MHA "Banks API Integration Specification 2.0"; RBI letter CO.DOS.KYC.No.S6675/11-01-006/2025-26 (05 Dec 2025).
# Off until a bank is onboarded: with enabled=false the inbound endpoints answer 503 and both jobs do nothing.
# Every credential below comes from the environment; I4C sends the real values to the bank's Nodal/SPOC by email.
fineract.i4c.enabled=${FINERACT_I4C_ENABLED:false}
# I4C has no Fineract tenant header; its calls are served by this tenant.
fineract.i4c.tenant-identifier=${FINERACT_I4C_TENANT:default}
# This bank's code in the I4C Bank Code Master (m_i4c_bank_master), reported as root_bankid.
fineract.i4c.bank-code=${FINERACT_I4C_BANK_CODE:0}
fineract.i4c.bank-name=${FINERACT_I4C_BANK_NAME:}
# Inbound (I4C -> bank): the username/password this bank issues to I4C. Give the bcrypt hash in production;
# the plain password is accepted only when no hash is set (local and UAT).
fineract.i4c.inbound.username=${FINERACT_I4C_INBOUND_USERNAME:}
fineract.i4c.inbound.password-hash=${FINERACT_I4C_INBOUND_PASSWORD_HASH:}
fineract.i4c.inbound.password=${FINERACT_I4C_INBOUND_PASSWORD:}
fineract.i4c.inbound.token-ttl-seconds=${FINERACT_I4C_TOKEN_TTL_SECONDS:900}
# Comma-separated source IPs I4C calls from (the public IPs I4C shares). Blank allows any IP - UAT only.
fineract.i4c.inbound.allowed-ips=${FINERACT_I4C_ALLOWED_IPS:}
# Trust X-Forwarded-For for the caller IP only when a reverse proxy in front of the CBS sets it.
fineract.i4c.inbound.trust-forwarded-for=${FINERACT_I4C_TRUST_FORWARDED_FOR:false}
# How a complaint push may authenticate (spec section 2: "Basic Auth / Auth 2.0", credentials "in every request"):
# TOKEN = Bearer from /auth/token, BASIC = Authorization: Basic user:password, BODY = username/password (or
# client_id/client_secret) inside the JSON. All check the same credentials and IP allowlist; list only what I4C uses.
fineract.i4c.inbound.auth-modes=${FINERACT_I4C_AUTH_MODES:TOKEN,BASIC,BODY}
# The client name the bank registers with I4C (spec: "client name, client ID, and client secret key"). The client ID
# and secret are the inbound username and password above; /auth/token also accepts them as client_id/client_secret.
fineract.i4c.inbound.client-name=${FINERACT_I4C_CLIENT_NAME:}
# "Encryption Logics" shared by I4C. The specification's samples (U2FsdGVkX1...) are the CryptoJS / OpenSSL
# passphrase format: AES-256-CBC, key and IV derived from the passphrase with EVP_BytesToKey(MD5).
# FIELDS - only username/password are encrypted (what the specification shows)
# ENVELOPE - the whole JSON body arrives as {"data":"<encrypted>"} and the bank answers the same way
# NONE - plain JSON
fineract.i4c.crypto.mode=${FINERACT_I4C_CRYPTO_MODE:FIELDS}
fineract.i4c.crypto.passphrase=${FINERACT_I4C_CRYPTO_PASSPHRASE:}
# Outbound (bank -> I4C): token then response. UAT/production URLs come from I4C.
fineract.i4c.outbound.base-url=${FINERACT_I4C_OUTBOUND_BASE_URL:}
fineract.i4c.outbound.auth-path=${FINERACT_I4C_OUTBOUND_AUTH_PATH:/auth/token}
fineract.i4c.outbound.response-path=${FINERACT_I4C_OUTBOUND_RESPONSE_PATH:/bank/response}
# Section 5 is titled "Ack Request Message from BANKS to I4C-MHA". The ack is always the reply to the push; set this
# path only if I4C also wants it as a separate call (sent before the response). Blank = no separate ack call.
fineract.i4c.outbound.ack-path=${FINERACT_I4C_OUTBOUND_ACK_PATH:}
fineract.i4c.outbound.username=${FINERACT_I4C_OUTBOUND_USERNAME:}
fineract.i4c.outbound.password=${FINERACT_I4C_OUTBOUND_PASSWORD:}
fineract.i4c.outbound.timeout-ms=${FINERACT_I4C_OUTBOUND_TIMEOUT_MS:15000}
fineract.i4c.outbound.max-attempts=${FINERACT_I4C_OUTBOUND_MAX_ATTEMPTS:8}
# Incoming credits carry no RRN in this CBS, so a complaint's transaction is matched on account, amount and date,
# within this many days either side of the reported date.
fineract.i4c.match.date-window-days=${FINERACT_I4C_MATCH_WINDOW_DAYS:2}
# Days a hold runs before it is due for review (MHA SOP: 90 days), and hours to intimate the customer.
fineract.i4c.hold.review-days=${FINERACT_I4C_HOLD_REVIEW_DAYS:90}
fineract.i4c.hold.intimation-hours=${FINERACT_I4C_HOLD_INTIMATION_HOURS:24}
# MRM money restoration. Outward NEFT / IMPS / RTGS restorations leave the suspect account for this pool savings
# account (a bank-owned client account) in the same transaction as the hold release, then go out from it. Empty = only
# restorations to accounts in this bank are possible.
fineract.i4c.restoration.pool-account-no=${FINERACT_I4C_RESTORATION_POOL_ACCOUNT:}
# MRM manual: above this amount in a single complaint the IO must give an FIR.
fineract.i4c.restoration.fir-threshold=${FINERACT_I4C_RESTORATION_FIR_THRESHOLD:50000}
# Ack codes for a refused push: SECTION5 = always 11 "Structure validation failed"; SECTION7 = the specific section 7
# code (40001 account number, 40004 amount, 40005 date). Repeated push: SAME_JOB = 00 with the original job_id;
# CODE_50003 = 50003 "Duplicate Transaction". A transaction_date more than max-future-days ahead is refused (40005).
fineract.i4c.ack.code-style=${FINERACT_I4C_ACK_CODE_STYLE:SECTION5}
fineract.i4c.ack.duplicate-mode=${FINERACT_I4C_ACK_DUPLICATE_MODE:SAME_JOB}
fineract.i4c.ack.max-future-days=${FINERACT_I4C_ACK_MAX_FUTURE_DAYS:1}
# Local stand-in for I4C (token, response receiver, push). Never enable in production.
fineract.i4c.simulator.enabled=${FINERACT_I4C_SIMULATOR_ENABLED:false}
@@ -174,4 +174,12 @@
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
<include file="parts/0600_clerk_withdrawal_request_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0610_cfcfrms_i4c_integration.xml" relativeToChangelogFile="true"/>
<include file="parts/0611_i4c_bank_master_seed.xml" relativeToChangelogFile="true"/>
<include file="parts/0612_i4c_intimation_channels.xml" relativeToChangelogFile="true"/>
<include file="parts/0613_i4c_grm_mrm.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) goes through the same request, manager approval and
cashier disbursement as a savings withdrawal, so the request carries the GL account and the cash GL it is paid
from. All four columns are nullable: savings, current and deposit requests do not use them.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0600_clerk_withdrawal_request_add_gl_columns" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_clerk_withdrawal_request"/>
<not><columnExists tableName="m_clerk_withdrawal_request" columnName="gl_account_id"/></not>
</preConditions>
<addColumn tableName="m_clerk_withdrawal_request">
<column name="gl_account_id" type="BIGINT"/>
<column name="contra_gl_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="currency_code" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Voucher correction: a cash deposit posted to the wrong savings account is unpassed, approved, re-entered against
the right account by the cashier and approved again. m_voucher_correction carries that request through its steps;
m_voucher gains a status so the original voucher can be REVOKED once its replacement exists.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0601_voucher_add_status" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_voucher" columnName="status"/></not>
</preConditions>
<addColumn tableName="m_voucher">
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="revoked_at" type="TIMESTAMP"/>
<column name="revoked_by_voucher_no" type="VARCHAR(30)"/>
</addColumn>
</changeSet>
<changeSet id="0601_create_voucher_correction" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_voucher_correction"/></not>
</preConditions>
<createTable tableName="m_voucher_correction">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="original_voucher_no" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="original_txn_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="wrong_savings_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="wrong_account_no" type="VARCHAR(50)"/>
<column name="wrong_client_name" type="VARCHAR(255)"/>
<column name="amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="payment_type_id" type="BIGINT"/>
<column name="txn_date" type="DATE"/>
<column name="status" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="reason" type="VARCHAR(1000)"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="unpass_by_id" type="BIGINT"/>
<column name="unpass_by_name" type="VARCHAR(255)"/>
<column name="unpass_at" type="TIMESTAMP"/>
<column name="unpass_approved_by_id" type="BIGINT"/>
<column name="unpass_approved_by_name" type="VARCHAR(255)"/>
<column name="unpass_approved_at" type="TIMESTAMP"/>
<column name="correct_savings_account_id" type="BIGINT"/>
<column name="correct_account_no" type="VARCHAR(50)"/>
<column name="correct_client_id" type="BIGINT"/>
<column name="correct_client_name" type="VARCHAR(255)"/>
<column name="correction_by_id" type="BIGINT"/>
<column name="correction_by_name" type="VARCHAR(255)"/>
<column name="correction_at" type="TIMESTAMP"/>
<column name="correction_approved_by_id" type="BIGINT"/>
<column name="correction_approved_by_name" type="VARCHAR(255)"/>
<column name="correction_approved_at" type="TIMESTAMP"/>
<column name="new_voucher_no" type="VARCHAR(30)"/>
<column name="new_txn_id" type="BIGINT"/>
<column name="activity_log" type="TEXT"/>
</createTable>
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_voucher">
<column name="original_voucher_no"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
The voucher itself carries no correction status: whether a voucher was unpassed, revoked or closed is read from
m_voucher_correction. 0601 had added status, revoked_at and revoked_by_voucher_no to m_voucher; nothing writes them
any more, so they are dropped and the voucher table is back to exactly what the registers raised.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0602_voucher_drop_status_columns" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_voucher" columnName="status"/>
</preConditions>
<dropColumn tableName="m_voucher" columnName="status"/>
<dropColumn tableName="m_voucher" columnName="revoked_at"/>
<dropColumn tableName="m_voucher" columnName="revoked_by_voucher_no"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.
-->
<!--
Cash Transfer is maker-checker: the person who allocates cash from the vault to a cashier raises a request, and the
allocation is posted only when a different person approves it. m_cash_transfer stays the table of posted transfers;
this table holds the requests, with the whole request body so the approval posts exactly what was asked for.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0603_create_cash_transfer_request" author="cbsnexora">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_cash_transfer_request"/></not>
</preConditions>
<createTable tableName="m_cash_transfer_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_date" type="DATE"/>
<column name="from_teller_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="to_cashier_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="from_party" type="VARCHAR(255)"/>
<column name="to_party" type="VARCHAR(255)"/>
<column name="note" type="VARCHAR(1000)"/>
<column name="total_amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="request_body" type="TEXT"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="maker_id" type="BIGINT"/>
<column name="maker_name" type="VARCHAR(255)"/>
<column name="maker_at" type="TIMESTAMP"/>
<column name="checker_id" type="BIGINT"/>
<column name="checker_name" type="VARCHAR(255)"/>
<column name="checker_at" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="cash_transfer_id" type="BIGINT"/>
</createTable>
<createIndex tableName="m_cash_transfer_request" indexName="idx_cash_transfer_request_status">
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,352 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
I4C (NCRP-CFCFRMS) API integration, per the I4C-MHA "Banks API Integration Specification 2.0" and RBI letter
CO.DOS.KYC.No.S6675/11-01-006/2025-26 (05 Dec 2025): real-time action on cyber-fraud complaints, and a record of the
integration's performance for periodic review.
The demo tables of 0226 (m_cfcfrms_*) are untouched; this is a separate, real integration.
m_i4c_access_token tokens issued to I4C (hash only, never the token itself)
m_i4c_request one complaint push from I4C: acknowledgement_no + the job_id the bank answered with
m_i4c_incident the incidents[] of a push: rrn, amount, disputed amount, layer, and what was matched
m_i4c_finding each transactions[] entry reported back (Put on Hold, Money Transfer To, ...)
m_i4c_hold the register of holds placed for I4C: amount held, prospective shortfall, 90-day review,
customer intimation
m_i4c_hold_txn the account hold transactions behind one register entry (the first hold and any top-ups)
m_i4c_hold_action_request maker-checker requests to release a hold, or to freeze / unfreeze debits on the account
m_i4c_outbox responses waiting to be (re)sent to I4C
m_i4c_api_log every call in either direction, for the performance review the RBI letter asks for
m_i4c_bank_master the specification's Bank Code Master (seeded by 0611)
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="0610-1-i4c-tables">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_request"/></not>
</preConditions>
<createTable tableName="m_i4c_access_token">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="token_hash" type="VARCHAR(64)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_i4c_access_token_hash"/></column>
<column name="client_ip" type="VARCHAR(64)"/>
<column name="issued_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="expires_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="revoked" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
</createTable>
<createTable tableName="m_i4c_request">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="acknowledgement_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="job_id" type="VARCHAR(64)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_i4c_request_job_id"/></column>
<column name="sub_category" type="VARCHAR(200)"/>
<column name="requestor" type="VARCHAR(50)"/>
<column name="payer_bank" type="VARCHAR(200)"/>
<column name="payer_bank_code" type="VARCHAR(10)"/>
<column name="mode_of_payment" type="VARCHAR(30)"/>
<column name="payer_mobile_number" type="VARCHAR(20)"/>
<column name="payer_account_number" type="VARCHAR(40)"/>
<column name="state" type="VARCHAR(100)"/>
<column name="district" type="VARCHAR(100)"/>
<column name="transaction_type" type="VARCHAR(50)"/>
<column name="wallet" type="VARCHAR(100)"/>
<column name="max_layer" type="INT"/>
<column name="payload_hash" type="VARCHAR(64)"><constraints nullable="false"/></column>
<column name="raw_payload" type="TEXT"/>
<column name="source_ip" type="VARCHAR(64)"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="processing_attempts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="processing_error" type="TEXT"/>
<column name="received_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="processed_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_i4c_request" indexName="idx_i4c_request_ack">
<column name="acknowledgement_no"/>
</createIndex>
<createIndex tableName="m_i4c_request" indexName="idx_i4c_request_status">
<column name="status"/>
</createIndex>
<createTable tableName="m_i4c_incident">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="request_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_incident_request" references="m_i4c_request(id)"/></column>
<column name="seq_no" type="INT"><constraints nullable="false"/></column>
<column name="layer" type="INT"><constraints nullable="false"/></column>
<column name="amount" type="DECIMAL(19,2)"/>
<column name="disputed_amount" type="DECIMAL(19,2)"/>
<column name="rrn" type="VARCHAR(64)"/>
<column name="transaction_date" type="DATE"/>
<column name="transaction_time" type="VARCHAR(12)"/>
<column name="card_first6" type="VARCHAR(6)"/>
<column name="card_last4" type="VARCHAR(4)"/>
<column name="card_length" type="VARCHAR(4)"/>
<column name="account_type" type="VARCHAR(10)"/>
<column name="account_id" type="BIGINT"/>
<column name="account_no" type="VARCHAR(40)"/>
<column name="client_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="matched_txn_id" type="BIGINT"/>
<column name="match_method" type="VARCHAR(30)"/>
<column name="outcome" type="VARCHAR(30)"/>
<column name="status_code" type="VARCHAR(4)"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="processed_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_i4c_incident" indexName="idx_i4c_incident_request">
<column name="request_id"/>
</createIndex>
<createTable tableName="m_i4c_finding">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="request_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_finding_request" references="m_i4c_request(id)"/></column>
<column name="incident_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_finding_incident" references="m_i4c_incident(id)"/></column>
<column name="txn_type_id" type="INT"><constraints nullable="false"/></column>
<column name="txn_type" type="VARCHAR(60)"><constraints nullable="false"/></column>
<column name="amount" type="DECIMAL(19,2)"/>
<column name="disputed_amount" type="DECIMAL(19,2)"/>
<column name="status_code" type="VARCHAR(4)"/>
<column name="entry_json" type="TEXT"><constraints nullable="false"/></column>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_i4c_finding" indexName="idx_i4c_finding_request">
<column name="request_id"/>
</createIndex>
<createTable tableName="m_i4c_hold">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="request_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_hold_request" references="m_i4c_request(id)"/></column>
<column name="incident_id" type="BIGINT"><constraints nullable="false" unique="true" uniqueConstraintName="uq_i4c_hold_incident" foreignKeyName="fk_i4c_hold_incident" references="m_i4c_incident(id)"/></column>
<column name="acknowledgement_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="account_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="account_no" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="client_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="layer" type="INT"><constraints nullable="false"/></column>
<column name="disputed_amount" type="DECIMAL(19,2)"><constraints nullable="false"/></column>
<column name="amount_held" type="DECIMAL(19,2)" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="shortfall" type="DECIMAL(19,2)" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="debit_frozen" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="held_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="review_due_on" type="DATE"><constraints nullable="false"/></column>
<column name="intimation_due_by" type="TIMESTAMP"/>
<column name="customer_intimated_at" type="TIMESTAMP"/>
<column name="intimation_mode" type="VARCHAR(30)"/>
<column name="intimation_reference" type="VARCHAR(200)"/>
<column name="intimated_by" type="VARCHAR(100)"/>
<column name="last_topup_at" type="TIMESTAMP"/>
<column name="released_at" type="TIMESTAMP"/>
<column name="release_reason_type" type="VARCHAR(40)"/>
<column name="release_reference" type="VARCHAR(200)"/>
</createTable>
<createIndex tableName="m_i4c_hold" indexName="idx_i4c_hold_status">
<column name="status"/>
</createIndex>
<createIndex tableName="m_i4c_hold" indexName="idx_i4c_hold_account">
<column name="account_type"/>
<column name="account_id"/>
</createIndex>
<createTable tableName="m_i4c_hold_txn">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="hold_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_hold_txn_hold" references="m_i4c_hold(id)"/></column>
<column name="account_txn_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="amount" type="DECIMAL(19,2)"><constraints nullable="false"/></column>
<column name="placed_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="released_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_i4c_hold_txn" indexName="idx_i4c_hold_txn_hold">
<column name="hold_id"/>
</createIndex>
<createTable tableName="m_i4c_hold_action_request">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="hold_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_hold_action_hold" references="m_i4c_hold(id)"/></column>
<column name="action" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="reason_type" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="order_reference" type="VARCHAR(200)"/>
<column name="order_date" type="DATE"/>
<column name="authority" type="VARCHAR(200)"/>
<column name="remarks" type="VARCHAR(1000)"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="maker_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="maker_name" type="VARCHAR(255)"/>
<column name="maker_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="checker_id" type="BIGINT"/>
<column name="checker_name" type="VARCHAR(255)"/>
<column name="checker_at" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
<column name="failure_message" type="VARCHAR(1000)"/>
</createTable>
<createIndex tableName="m_i4c_hold_action_request" indexName="idx_i4c_hold_action_status">
<column name="status"/>
</createIndex>
<createTable tableName="m_i4c_outbox">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="request_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_outbox_request" references="m_i4c_request(id)"/></column>
<column name="message_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="payload" type="TEXT"><constraints nullable="false"/></column>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="attempts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="next_attempt_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="last_error" type="TEXT"/>
<column name="http_status" type="INT"/>
<column name="response_body" type="TEXT"/>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="sent_at" type="TIMESTAMP"/>
</createTable>
<createIndex tableName="m_i4c_outbox" indexName="idx_i4c_outbox_due">
<column name="status"/>
<column name="next_attempt_at"/>
</createIndex>
<createTable tableName="m_i4c_api_log">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="direction" type="VARCHAR(3)"><constraints nullable="false"/></column>
<column name="endpoint" type="VARCHAR(300)"><constraints nullable="false"/></column>
<column name="http_status" type="INT"/>
<column name="response_code" type="VARCHAR(10)"/>
<column name="acknowledgement_no" type="VARCHAR(40)"/>
<column name="duration_ms" type="BIGINT"/>
<column name="client_ip" type="VARCHAR(64)"/>
<column name="request_body" type="TEXT"/>
<column name="response_body" type="TEXT"/>
<column name="error" type="VARCHAR(1000)"/>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_i4c_api_log" indexName="idx_i4c_api_log_created">
<column name="created_at"/>
</createIndex>
<createTable tableName="m_i4c_bank_master">
<column name="bank_code" type="INT"><constraints primaryKey="true" nullable="false"/></column>
<column name="bank_name" type="VARCHAR(255)"><constraints nullable="false"/></column>
<column name="entity_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
<!-- First four letters of the bank's IFSC, so a payee IFSC can be reported with its I4C bank code. -->
<column name="ifsc_prefix" type="VARCHAR(4)"/>
</createTable>
<createIndex tableName="m_i4c_bank_master" indexName="idx_i4c_bank_master_ifsc">
<column name="ifsc_prefix"/>
</createIndex>
</changeSet>
<!-- Permissions. Only the release / freeze approval is maker-checker, and that is enforced in the handler (the maker
cannot approve their own request) rather than through the command queue, the same way cash transfer does it. -->
<changeSet author="fineract" id="0610-2-i4c-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'READ_I4C'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_I4C"/>
<column name="entity_name" value="I4C"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="RETRY_I4C"/>
<column name="entity_name" value="I4C"/>
<column name="action_name" value="RETRY"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="REQUESTACTION_I4CHOLD"/>
<column name="entity_name" value="I4CHOLD"/>
<column name="action_name" value="REQUESTACTION"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="APPROVEACTION_I4CHOLD"/>
<column name="entity_name" value="I4CHOLD"/>
<column name="action_name" value="APPROVEACTION"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="INTIMATE_I4CHOLD"/>
<column name="entity_name" value="I4CHOLD"/>
<column name="action_name" value="INTIMATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0610-3-i4c-dispatch-job">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'I4C Dispatch'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="I4C Dispatch"/>
<column name="display_name" value="I4C Dispatch"/>
<column name="short_name" value="I4CDISP"/>
<!-- Every minute: sends responses waiting in m_i4c_outbox, and processes any complaint whose immediate
after-commit processing did not finish (server restart, failure). -->
<column name="cron_expression" value="0 0/1 * * * ?"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="I4C DispatchJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0610-4-i4c-hold-review-job">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'I4C Hold Review'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="I4C Hold Review"/>
<column name="display_name" value="I4C Hold Review"/>
<column name="short_name" value="I4CHREV"/>
<!-- Every 15 minutes: tops up prospective holds from new credits (the money that arrives after the
complaint is the money that would otherwise walk out). -->
<column name="cron_expression" value="0 0/15 * * * ?"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="I4C Hold ReviewJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,593 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
I4C Bank Code Master, as published in the I4C-MHA "Banks API Integration Specification 2.0" (Bank Code Master
table). Every bank must maintain it: payee_bank_code / root_bankid in the responses sent to I4C are these codes.
Entity types as printed there: BNK bank, WLT wallet, MER merchant, PPI prepaid instrument, CBD, INS.
Generated from the specification table; a later revision from I4C is loaded the same way, by a new changeset.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="0611-1-i4c-bank-master-seed">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_i4c_bank_master</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_i4c_bank_master (bank_code, bank_name, entity_type) VALUES
(3, 'Axis Bank', 'BNK'),
(4, 'BANK OF BAHRAIN AND KUWAIT B.S.C', 'BNK'),
(5, 'Bank of Baroda (Including Vijaya Bank and Dena Bank)', 'BNK'),
(7, 'Bank of India', 'BNK'),
(8, 'Bank of Maharashtra', 'BNK'),
(9, 'Canara Bank (including Syndicate Bank)', 'BNK'),
(10, 'Central Bank of India', 'BNK'),
(11, 'City Union Bank', 'BNK'),
(13, 'Deutsche Bank', 'BNK'),
(14, 'DCB Bank (Development Credit Bank)', 'BNK'),
(15, 'Dhanlaxmi Bank', 'BNK'),
(16, 'Federal Bank', 'BNK'),
(17, 'ICICI Bank', 'BNK'),
(18, 'IDBI Bank', 'BNK'),
(19, 'Indian Bank (including Allahabad Bank)', 'BNK'),
(20, 'Indian Overseas Bank', 'BNK'),
(21, 'IndusInd Bank', 'BNK'),
(23, 'Jammu and Kashmir Bank', 'BNK'),
(24, 'Karnataka Bank Ltd', 'BNK'),
(25, 'Karur Vysya Bank', 'BNK'),
(27, 'DBS Bank (Including Lakshmi Vilas Bank)', 'BNK'),
(29, 'Punjab National Bank (including Oriental Bank of Commerce and United Bank of India)', 'BNK'),
(31, 'Punjab &amp; Sind Bank', 'BNK'),
(33, 'South Indian Bank', 'BNK'),
(36, 'State Bank of India', 'BNK'),
(41, 'Tamilnad Mercantile Bank Ltd.', 'BNK'),
(42, 'UCO Bank', 'BNK'),
(43, 'Union Bank of India (including Andhra Bank and Corporation Bank)', 'BNK'),
(46, 'Yes Bank', 'BNK'),
(52, 'Paytm Payment Bank', 'BNK'),
(53, 'Amazon Pay', 'WLT'),
(54, 'Google Pay', 'WLT'),
(55, 'PhonePe', 'WLT'),
(56, 'Mobikwik', 'WLT'),
(58, 'Citi MasterPass', 'MER'),
(62, 'Others', 'BNK'),
(63, 'Flipkart', 'MER'),
(64, 'Catholic Syrian Bank LTD. (CSB)', 'BNK'),
(65, 'HDFC Bank', 'BNK'),
(66, 'IDFC First Bank', 'BNK'),
(67, 'Kotak Mahindra Bank', 'BNK'),
(68, 'Nainital Bank', 'BNK'),
(70, 'Fino Payments Bank', 'BNK'),
(74, 'mRupee', 'WLT'),
(78, 'Freecharge', 'WLT'),
(79, 'ItzCash', 'WLT'),
(86, 'Chiller', 'WLT'),
(87, 'Amazon India', 'MER'),
(88, 'Paytm', 'MER'),
(89, 'Snapdeal', 'MER'),
(91, 'Zivame', 'MER'),
(92, 'Jabong', 'MER'),
(93, 'ShopClues', 'MER'),
(94, 'Myntra', 'MER'),
(96, 'Bigbasket', 'MER'),
(97, 'Pepperfry', 'MER'),
(98, 'Limeroad', 'MER'),
(100, 'Clovia', 'MER'),
(102, 'Koovs', 'MER'),
(103, 'Yepme', 'MER'),
(104, 'Grofers', 'MER'),
(105, 'Rediff', 'MER'),
(106, 'ZopNow', 'MER'),
(107, 'HealthKart', 'MER'),
(108, 'Lenskart', 'MER'),
(111, 'ITC Portal', 'MER'),
(112, 'iBhejo', 'MER'),
(114, 'SriSriTattva', 'MER'),
(115, 'Croma', 'MER'),
(117, 'Naaptol India', 'MER'),
(118, 'Natures Basket', 'MER'),
(119, 'Giskaa', 'MER'),
(120, 'Homeshop18', 'MER'),
(121, 'Future Bazaar', 'MER'),
(122, 'Yebhi', 'MER'),
(124, 'Fabindia', 'MER'),
(125, 'The North East Store', 'MER'),
(126, 'Archies Online', 'MER'),
(127, 'Khadi Natural', 'MER'),
(129, 'Just Herbs', 'MER'),
(130, 'Shahnaz Husain Group', 'MER'),
(131, 'Brindco', 'MER'),
(132, 'Flavours Of My City', 'MER'),
(133, 'Firstcry', 'MER'),
(134, 'Netmeds', 'MER'),
(135, 'Payu', 'WLT'),
(136, 'Citi Bank', 'BNK'),
(138, 'NSDL Payments Bank Ltd', 'BNK'),
(139, 'State Bank of India (Credit card)', 'BNK'),
(140, 'Others', 'MER'),
(141, 'Airtel Payments Bank', 'BNK'),
(142, 'Others', 'WLT'),
(143, 'THE GUJARAT STATE COOPERATIVE BANK', 'BNK'),
(144, 'Ratnakar Bank Limited (RBL)', 'BNK'),
(145, 'Ujjivan Small Finance Bank Ltd', 'BNK'),
(146, 'Jio Payments Bank Ltd', 'BNK'),
(147, 'Equitas Bank', 'BNK'),
(148, 'AU Bank', 'BNK'),
(149, 'Razorpay', 'WLT'),
(151, 'Standard Chartered Bank', 'BNK'),
(152, 'Nobroker Technologies Solution Private Ltd', 'MER'),
(153, 'Suryoday Bank', 'BNK'),
(154, 'CRED', 'MER'),
(155, 'Cashfree', 'MER'),
(156, 'India Post Payments Bank', 'BNK'),
(157, 'Chhattisgarh Gramin Bank', 'BNK'),
(158, 'Billdesk', 'MER'),
(159, 'Khata Book', 'MER'),
(160, 'Kerala Gramin Bank', 'BNK'),
(161, 'Puduvai Bharathiar Grama', 'BNK'),
(162, 'Karnataka Gramin Bank', 'BNK'),
(163, 'Meghalaya Rural Bank', 'BNK'),
(164, 'Vidharbha Konkan Gramin Bank', 'BNK'),
(165, 'Tamil Nadu Grama Bank', 'BNK'),
(166, 'HP Gramin Bank', 'BNK'),
(167, 'Baroda U.P. Bank', 'BNK'),
(168, 'Baroda Gujarat Gramin Bank', 'BNK'),
(169, 'Telangana Grameena Bank (including DECCAN GRAMIN BANK)', 'BNK'),
(170, 'Andhra Pragathi Grameena Bank', 'BNK'),
(171, 'Andhra Pradesh Grameena Vikas Bank', 'BNK'),
(172, 'Bharat Pe', 'MER'),
(173, 'Dhani Pay', 'WLT'),
(174, 'Ease Buzz', 'WLT'),
(175, 'State Bank Of Mauritius (SBM)', 'BNK'),
(176, 'Paytm', 'WLT'),
(177, 'Fincare Small Finance Bank', 'BNK'),
(178, 'POS ATM PURCH', 'WLT'),
(179, 'Bandhan Bank', 'BNK'),
(180, 'ESAF Small Finance Bank', 'BNK'),
(181, 'ATOM', 'MER'),
(182, 'MagicBricks', 'MER'),
(183, 'Paulpay', 'MER'),
(184, 'Utkarsh Small Finance Bank Limited', 'BNK'),
(185, 'Bharat Co-operative Bank (M) Ltd', 'BNK'),
(186, 'American Express', 'BNK'),
(187, 'The Kangra Central Co-operative Bank Ltd', 'BNK'),
(188, 'Baroda Rajasthan Kshetriya Gramin Bank', 'BNK'),
(189, 'Midas Corporate Park', 'BNK'),
(190, 'Maharashtra Gramin Bank', 'BNK'),
(191, 'The Nawanagar Co-operative Bank Ltd', 'BNK'),
(192, 'The Gandhidham Mercantile Co-Op Bank Ltd', 'BNK'),
(193, 'Dharti Bank', 'BNK'),
(194, 'Uttarakhand Gramin Bank (including Uttaranchal Gramin Bank)', 'BNK'),
(195, 'The Zoroastrian Co-operative Bank Ltd.', 'BNK'),
(196, 'Saurashtra Gramin Bank', 'BNK'),
(197, 'Navnirman Bank Bhavan', 'BNK'),
(198, 'Adarsh Co-operative Bank Ltd', 'BNK'),
(199, 'Sarva Haryana Gramin Bank (including Haryana Gramin Bank)', 'BNK'),
(200, 'THE BHAGYODAYA CO-OPERATIVE BANK', 'BNK'),
(201, 'The Mehsana Urban Co-Op. Bank', 'BNK'),
(202, 'Rajkot Peoples Co-Operative Bank Ltd', 'BNK'),
(203, 'The Anand Mercantile Co-Op.Bank Ltd', 'BNK'),
(204, 'Rajkot Nagrik Sahakari Bank', 'BNK'),
(205, 'The Karnavati Co-Op. Bank Ltd', 'BNK'),
(206, 'Rajasthan Marudhara Gramin Bank', 'BNK'),
(207, 'The Veraval Peoples Co Op Bank Ltd', 'BNK'),
(208, 'Bank of Baroda (Credit card)', 'BNK'),
(209, 'Qwikcilver Solutions', 'MER'),
(210, 'Saraswat Co-operative Bank Ltd.', 'BNK'),
(211, 'Jana Small Finance Bank - Jana Bank', 'BNK'),
(212, 'The Bhavnagar District Co-operative Bank Ltd.', 'BNK'),
(213, 'THE BHARUCH DISTRICT CENTRAL CO OPERATIVE BANK LTD.', 'BNK'),
(214, 'The Banaskantha District Central Co-operative Bank Ltd.', 'BNK'),
(215, 'VALSAD DISTRICT CENTRAL CO. OP. BANK LTD.', 'BNK'),
(216, 'Dakshin Bihar Gramin Bank', 'BNK'),
(217, 'THE AHMEDABAD DISTRICT CO-OPERATIVE BANK LTD.', 'BNK'),
(218, 'Citizens Co operative Bank Ltd.', 'BNK'),
(219, 'The Rajkot Commercial Co-operative Bank Ltd.', 'BNK'),
(220, 'Darussalam Co-operative Urban Bank Ltd', 'BNK'),
(221, 'Sterling Urban Co-op Bank Ltd', 'BNK'),
(222, 'The Kalupur Commercial Co-op. Bank Ltd', 'BNK'),
(223, 'The Navnirman co op Bank Ltd.', 'BNK'),
(224, 'SBPP Co-operative Bank Ltd.', 'BNK'),
(225, 'Manappuram Finance Ltd.', 'BNK'),
(226, 'PRIME CO-OPERATIVE BANK LTD.', 'BNK'),
(227, 'Shivalik Small Finance Bank.', 'BNK'),
(228, 'The Sutex Co-operative Bank Ltd.', 'BNK'),
(229, 'Amreli Jilla Madhyastha Sahakari Bank Ltd.', 'BNK'),
(230, 'Sarvodaya commercial co-operative Bank Limited.', 'BNK'),
(231, 'KANKARIA MANINAGAR NAGRIK SAHAKARI BANK LTD.', 'BNK'),
(232, 'Gujarat Ambuja Co-Op Bank Ltd.', 'BNK'),
(233, 'Union Co-operative Bank Ltd.', 'BNK'),
(234, 'Deogiri Nagari Sahakari Bank Ltd. Aurangabad.', 'BNK'),
(236, 'Capital Small Finance Bank Limited', 'BNK'),
(237, 'Nutan Nagarik Sahakari Bank Ltd.', 'BNK'),
(238, 'The Sarvodaya Sahakari Bank Ltd. modasa', 'BNK'),
(239, 'Akhand Anand Co. Op. Bank Ltd.', 'BNK'),
(240, 'THE MAHUDHA NAGRIK SAHAKARI BANK LTD.', 'BNK'),
(241, 'THE VARACHHA CO.OP.BANK LTD.', 'BNK'),
(242, 'Bhatpara Naihati Co-Operative Bank Ltd.', 'BNK'),
(243, 'Shri Rajkot District Co. Operative Bank Ltd.', 'BNK'),
(244, 'MODEL COOPERATIVE BANK LTD', 'BNK'),
(245, 'NKGSB CO-OPERATIVE BANK LTD', 'BNK'),
(246, 'The Financial Co-operative bank ltd', 'BNK'),
(247, 'The Co-Operative Bank Of Rajkot ltd', 'BNK'),
(248, 'THE VEPAR UDHYOG VIKAS SAHAKARI BANK LTD', 'BNK'),
(249, 'The Jalgaon Peoples Co-op. Bank Ltd', 'BNK'),
(250, 'Saraspur Nagarik Co.Operative Bank Ltd', 'BNK'),
(251, 'UMA COOPERATIVE BANK LTD', 'BNK'),
(252, 'Punjab Gramin Bank', 'BNK'),
(253, 'Patan Nagarik Sahakari Bank Ltd', 'BNK'),
(254, 'GP Parsik Sahakari Bank Ltd', 'BNK'),
(255, 'BASSEIN CATHOLIC CO-OP BANK LTD', 'BNK'),
(256, 'THE GODHRA URBAN CO-OP BANK LTD', 'BNK'),
(257, 'Vasai Vikas Sahakari Bank Ltd', 'BNK'),
(258, 'The Greater Bombay Co-operative Bank Limited', 'BNK'),
(259, 'THE PANCHMAHAL DIST COOP BANK LTD', 'BNK'),
(260, 'Barclays Bank PLC', 'BNK'),
(261, 'The Junagadh Jilla Sahakari Bank Ltd', 'BNK'),
(262, 'The Dahod Urban Co. Op. bank Ltd.', 'BNK'),
(263, 'The Surat Peoples Co-operative Bank Ltd.', 'BNK'),
(264, 'Shree Mahuva Nagrik Sahakari Bank Ltd.', 'BNK'),
(265, 'HSBC Bank.', 'BNK'),
(266, 'The Gandevi People''s Co-Op.Bank Ltd.', 'BNK'),
(267, 'Bajaj Finance Ltd.', 'WLT'),
(268, 'Spice Money.', 'WLT'),
(269, 'Kallappanna Awade Ichalkaranji Janata Sahakari Bank Ltd', 'BNK'),
(271, 'Nykaa', 'MER'),
(272, 'Housing.com', 'MER'),
(273, 'Pay1 (Mindsarray Network PVT LTD)', 'MER'),
(274, 'SVC Co-operative Bank Limited', 'BNK'),
(275, 'Dhanera Mercantile Co Op Bank Ltd', 'BNK'),
(276, 'THE JAMNAGAR MAHILA SAHAKARI BANK LTD', 'BNK'),
(277, 'SHREE KADI NAGARIK SAHAKARI BANK LTD', 'BNK'),
(278, 'THE CO-OPERATIVE BANK OF MEHSANA LTD', 'BNK'),
(280, 'THE KUKARWADA NAGARIK SAHAKARI BANK LTD', 'BNK'),
(281, 'SURAT NATIONAL CO-OP. BANK LTD.', 'BNK'),
(282, 'THE BAVLA NAGRIK SAHAKARI BANK LTD.', 'BNK'),
(283, 'THE MEHSANA DISTRICT CENTRAL CO-OP BANK LTD', 'BNK'),
(284, 'THE MALPUR NAGARIK SAHAKARI BANK LTD', 'BNK'),
(285, 'SHREE BOTAD MERCANTILE COOP BANK LTD', 'BNK'),
(286, 'THE BOTAD PEOPLES CO OPERATIVE BANK LTD', 'BNK'),
(287, 'M S CO-OPERATIVE BANK LTD.', 'BNK'),
(288, 'THE SURAT DISTRICT CO. OP. BANK LTD.', 'BNK'),
(289, 'PRAGATI SAHAKARI BANK LTD.', 'BNK'),
(290, 'SABARKANTHA DISTRICT COOPERATIVE BANK LTD.', 'BNK'),
(291, 'THE BHUJ COMMERCIAL CO OPERATIVE BANK LTD.', 'BNK'),
(292, 'JIVAN COMMERCIAL CO OPERATIVE BANK LTD.', 'BNK'),
(293, 'THE BARDOLI NAGRIK SAHAKARI BANK LTD.', 'BNK'),
(294, 'THE UDAIPUR MAHILA SAMRIDHI URBAN CO OPERATIVE BANK LTD.', 'BNK'),
(295, 'APNA SAHAKARI BANK LTD.', 'BNK'),
(296, 'SHRI MAHILA SEWA SAHAKARI BANK LTD.', 'BNK'),
(297, 'THE VERAVAL MERCANTILE CO-OP BANK LTD.', 'BNK'),
(298, 'THE SABARKANTHA DISTRICT CENTRAL COOPERATIVE BANK LTD.', 'BNK'),
(299, 'HIMATNAGAR NAGRIK SAHAKARI BANK LTD', 'BNK'),
(300, 'BHILWARA URBAN COOPERATIVE BANK LTD', 'BNK'),
(301, 'PORBANDAR COMMERCIAL CO OPERATIVE BANK LTD', 'BNK'),
(302, 'BHAVNAGAR MAHILA NAGRIK SAHAKARI BANK LTD', 'BNK'),
(303, 'THE KAIRA DISTRICT CENTRAL CO-OPERATIVE BANK LTD', 'BNK'),
(304, 'THE MODASA NAGARIK SAHAKARI BANK LTD', 'BNK'),
(305, 'SANMITRA MAHILA NAGARI SAHAKARI BANK MARYADIT, CHANDRAPUR', 'BNK'),
(306, 'ANDAMAN &amp; NICOBAR STATE COOPERATIVE BANK', 'BNK'),
(307, 'BAJAJ ALLIANZ GENERAL INSURANCE', 'INS'),
(308, 'MAX LIFE INSURANCE', 'INS'),
(309, 'THE AKOLA JANATA COMM. CO-OP BANK LTD', 'BNK'),
(310, 'INDRAPRASTHA SEHKARI BANK LTD', 'BNK'),
(311, 'MARATHA CO-OPERATIVE BANK LTD', 'BNK'),
(312, 'PALI URBAN CO-OPERATIVE BANK LTD.', 'BNK'),
(313, 'SARDAR SINGH NAGRIK SAHKARI BANK MARYADIT', 'BNK'),
(314, 'TAMIL NADU STATE APEX COOP BANK', 'BNK'),
(315, 'NEW INDIA CO-OPERATIVE BANK LTD.', 'BNK'),
(316, 'RELIANCE PAYMENT SOLUTIONS LIMITED', 'MER'),
(317, 'TIRUNELVELI DISTRICT CENTRAL COOPERATIVE BANK LTD', 'BNK'),
(318, 'SHREE MAHAYOGI LAKSHMAMMA CO-OPERATIVE BANK LTD', 'BNK'),
(319, 'MAHESH SAHAKARI BANK LTD.', 'BNK'),
(320, 'BUSINESS CO-OPERATIVE BANK LTD.', 'BNK'),
(321, 'LIFE INSURANCE CORPORATION', 'INS'),
(322, 'THE VELLORE DISTRICT CENTRAL COOPERATIVE BANK', 'BNK'),
(323, 'VIJAY COMMERCIAL CO. OPERATIVE BANK LTD.', 'BNK'),
(324, 'THE SAURASHTRA CO-OPERATIVE BANK LTD.', 'BNK'),
(325, 'BALASINOR NAGARIK SAHAKARI BANK LTD', 'BNK'),
(326, 'THE KANCHEEPURAM CENTRAL COOPERATIVE BANK LTD', 'BNK'),
(327, 'TRI O TECH SOLUTIONS PRIVATE LIMITED', 'WLT'),
(328, 'CHENNAI CENTRAL CO-OPERATIVE BANK LTD.', 'BNK'),
(329, 'THE AHMEDABAD MERCANTILE CO-OPERATIVE. BANK LTD', 'BNK'),
(331, 'THE BARODA CENTRAL CO OP BANK LTD', 'BNK'),
(332, 'UTTARKASHI ZILA SAHKARI BANK LTD.', 'BNK'),
(333, 'SHIVA SAHAKARI BANK NIYAMITA', 'BNK'),
(335, 'ASSOCIATE CO OP BANK LTD', 'BNK'),
(336, 'POCHAMPALLY CO-OP URBAN BANK LTD', 'BNK'),
(337, 'THE MATTANCHERRY SARVAJANIK CO-OPERATIVE BANK LTD.', 'BNK'),
(338, 'GODAVARI URBAN CO. OP. BANK LTD.', 'BNK'),
(339, 'OPEN', 'WLT'),
(340, 'AIRPAY', 'WLT'),
(341, 'COMPUTER AGE MANAGEMENT SERVICES LTD.', 'WLT'),
(342, 'PAYSHARP PRIVATE LIMITED', 'WLT'),
(343, 'EPAYNOW', 'WLT'),
(344, 'MPURSE', 'WLT'),
(345, 'TATA PAYMENTS LIMITED', 'MER'),
(346, 'THE GANDHIDHAM CO-OPERTIVE BANK LTD', 'BNK'),
(347, 'PINE LABS', 'WLT'),
(348, 'TELANGANA STATE CO-OP APEX BANK(SPO-IDBI)', 'BNK'),
(349, 'TRI O TECH SOLUTIONS PRIVATE LIMITED', 'MER'),
(350, 'Prathama UP Gramin Bank', 'BNK'),
(351, 'Eroute Technologies Private LIMITED', 'MER'),
(352, 'Sumitomo Mitsui Banking Corporation (SMBC)', 'BNK'),
(353, 'Giottus', 'WLT'),
(354, 'CHATTISGARH GRAMIN BANK)', 'BNK'),
(355, 'UNITY SMALL FINANCE BANK', 'BNK'),
(356, 'UNOCOIN', 'WLT'),
(357, 'ZEBPAY', 'WLT'),
(358, 'WAZIRX', 'WLT'),
(359, 'BITBNS', 'WLT'),
(360, 'Madhyanchal Gramin Bank', 'BNK'),
(361, 'JANATA SAHAKARI BANK PUNE', 'BNK'),
(362, 'Sri Guru Raghavendra Sahakara Bank Niyamitha', 'BNK'),
(363, 'Navi Mumbai Co-Operative Bank Limited', 'BNK'),
(364, 'The Akola District Central Co-op. Bank Ltd', 'BNK'),
(365, 'The Chandigarh State Cooperative Bank Ltd.', 'BNK'),
(366, 'COINDCX', 'WLT'),
(367, 'BUYUCOIN', 'WLT'),
(368, 'THE COSMOS CO-OP. BANK LTD.', 'BNK'),
(369, 'JAMMU and KASHMIR Grameen bank', 'BNK'),
(370, 'Mangalore catholic Co Operative bank Ltd (M.C.C)', 'BNK'),
(372, 'TJSB Sahakari Bank Limited', 'BNK'),
(373, 'Jharkhand Rajya Gramin Bank', 'BNK'),
(374, 'THE KHAMGAON URBAN COOP BANK LTD KHAMGAON', 'BNK'),
(375, 'The Baramati Sahakari Bank Limited', 'BNK'),
(376, 'RESILIENT INNOVATIONS PVT LTD', 'MER'),
(377, 'OPEN FINANCIAL TECHNOLOGY', 'MER'),
(378, 'DREAMPLUG PAYTECH SOLUTIONS PVT LTD', 'MER'),
(379, 'Shri Shivayogi Murughendra Swami Urban Co-Op. Bank Ltd', 'BNK'),
(380, 'Sri Chatrapati Shivaji Maharaj Sahakari Bank Niyamith', 'BNK'),
(381, 'SAPTAGIRI GRAMEENA BANK', 'BNK'),
(382, 'Thane Bharat Sahakari Bank Ltd', 'BNK'),
(383, 'Suvarnayug Sahakari Bank Ltd.', 'BNK'),
(384, 'Contai Co-operative Bank Ltd', 'BNK'),
(385, 'Madhya Bihar Gramin Bank', 'BNK'),
(386, 'MAHAVEER CO-OPERATIVE BANK', 'BNK'),
(387, 'The Suvikas Peoples Co Operative Bank Ltd', 'BNK'),
(388, 'Coastal Local Area Bank', 'BNK'),
(389, 'The Himachal Pradesh State Co-operative Bank Ltd', 'BNK'),
(390, 'Associate Co-Operative Bank Ltd', 'BNK'),
(391, 'Samarth Sahakari Bank (Solapur)', 'BNK'),
(392, 'PRAGATHI KRISHNA GRAMIN BANK(DIRECT)', 'BNK'),
(393, 'The Gayatri Co-operative Urban Bank Ltd', 'BNK'),
(394, 'Himachal Pradesh Gramin Bank', 'BNK'),
(395, 'AMBARNATH JAIHIND CO OP BANK', 'BNK'),
(396, 'MIZORAM RURAL BANK', 'BNK'),
(397, 'Aryavart Bank', 'BNK'),
(398, 'KODINAR TALUKA CO-OP. BANKING UNION LTD.', 'BNK'),
(399, 'TRANSCORP INTERNATIONAL LIMITED', 'WLT'),
(400, 'Pittoragarh Zila Sahakari Bank Ltd', 'BNK'),
(401, 'The Adarsh Co-operative Urban Bank Ltd.', 'BNK'),
(402, 'ADARSH MAHILA NAGARI SAHAKARI BANK LTD AURANGABAD', 'BNK'),
(403, 'PRIYADARSHANI NAGARI SAHAKARI BANK MARYADIT JALNA', 'BNK'),
(404, 'VISHWESHAR SAHKARI BANK LTD PUNE', 'BNK'),
(405, 'Rajadhani Co-op. Urban Bank Ltd', 'BNK'),
(406, 'THE GADCHIROLI DISTRICT CENTRAL COOP BANK LTD', 'BNK'),
(407, 'DOMBIVILI NAGARIK SAHAKARI BANK', 'BNK'),
(408, 'TRANSERV PRIVATE LIMITED-Prepaid Bank-PPI', 'MER'),
(409, 'SAMRUDHI CO-OP BANK', 'BNK'),
(410, 'The Tirupati Urban Co-operative Bank Ltd.', 'BNK'),
(411, 'THE AHMEDNAGAR DISTRICT CENTRAL CO-OPERATIVE BANK LTD', 'BNK'),
(412, 'The Vijay Co-operative Bank Ltd.', 'BNK'),
(413, 'Janaseva Sahakari Bank ltd,Pune', 'BNK'),
(414, 'JANASEVA SAHAKARI BANK BORIVALI LTD', 'BNK'),
(415, 'THE SARANGPUR CO OP BANK LTD', 'BNK'),
(416, 'KERALA STATE CO-OPERATIVE BANK', 'BNK'),
(417, 'BHARAT PETROLEUM CORPORATION LIMITED', 'MER'),
(418, 'THE BARODA CITY CO. OPERATIVE BANK LTD', 'BNK'),
(419, 'INFIBEAM AVENUES LIMITED (CCAVENUE)', 'WLT'),
(420, 'THE VAISH COOPERATIVE ADARSH BANK LTD.', 'BNK'),
(421, 'Abhyudaya Co-operative Bank', 'BNK'),
(422, 'The Agrasen Co-Operative Urban Bank Limited', 'BNK'),
(423, 'Amreli Nagrik Sahakari Bank Ltd.', 'BNK'),
(424, 'AMRELI JILLA MADHYASTHA SAHKARI BANK.', 'BNK'),
(425, 'The Andhra Pradesh Mahesh Co-operative Urban Bank Ltd', 'BNK'),
(426, 'Arvind Sahakari Bank Ltd.', 'BNK'),
(427, 'The Bundi Urban Co-operative Bank Ltd', 'BNK'),
(428, 'Baran Nagarik Sahakari Bank Ltd.', 'BNK'),
(429, 'CHAITANYA GODAVARI GRAMEENA BANK', 'BNK'),
(430, 'CITIBANK CORPORATE', 'BNK'),
(431, 'Shri Chhatrapati Rajarshi Shahu Urban Co-op.Bank Ltd', 'BNK'),
(432, 'Garagepreneurs Internet Private Limited', 'MER'),
(433, 'GS Mahanagar Coop bank', 'BNK'),
(434, 'The Andhra Pradesh State Co-operative Bank Ltd', 'BNK'),
(435, 'The Kanakamahalakshmi Co-operative Bank Ltd', 'BNK'),
(436, 'The Kangra Co-operative Bank', 'BNK'),
(437, 'Kolhapur Dist. Central Co-Op. Bank Ltd.', 'BNK'),
(438, 'Kalyan Janata Sahakari Bank', 'BNK'),
(439, 'Kolhapur Urban Co-operative Bank', 'BNK'),
(440, 'MANIPUR RURAL BANK', 'BNK'),
(441, 'MALVIYA URBAN COOPERATIVE BANK LTD', 'BNK'),
(442, 'Mizoram Urban Co-Operative Development Bank Ltd', 'BNK'),
(443, 'NORTH EAST SMALL FINANCE BANK', 'BNK'),
(444, 'THE NASIK MERCHANTS CO OPERATIVE BANK LTD NASHIK', 'BNK'),
(445, 'PASCHIM BANGA GRAMIN BANK', 'BNK'),
(446, 'Pune Peoples Co-Op. Bank', 'BNK'),
(447, 'RAJARSHI SHAHU SAHAKARI BANK LTD', 'BNK'),
(448, 'SUCO SOUHARDA SAHAKARI BANK LTD', 'BNK'),
(449, 'SUNDARLAL SAWJI BANK LTD', 'BNK'),
(450, 'SHREE WARANA SAHAKARIK BANK LTD WARANANAGAR', 'BNK'),
(451, 'The Tirur Urban Cooperative Bank Ltd', 'BNK'),
(452, 'Uttar Pradesh Co-operative Bank Ltd.', 'BNK'),
(453, 'The Visakhapatnam Cooperative Bank Ltd.', 'BNK'),
(454, 'BALANCEHERO INDIA PRIVATE LIMITED', 'WLT'),
(455, 'Assam Gramin Vikash Bank', 'BNK'),
(456, 'THE VAISH COOPERATIVE NEW BANK LTD', 'BNK'),
(457, 'JPMORGAN', 'BNK'),
(458, 'COINSWITCH', 'WLT'),
(459, 'Bank of America', 'BNK'),
(460, 'Textile Traders Co-operative Bank LTD', 'BNK'),
(461, 'Vallabh Vidyanagar Commercial CO-OPERATIVE BANK Ltd.', 'BNK'),
(462, 'Gondal Nagrik Sahkari Bank Ltd.', 'BNK'),
(463, 'The Chhapi Nagrik Sahakari Bank Ltd', 'BNK'),
(464, 'The Social Co-operative Bank Ltd.', 'BNK'),
(465, 'Ghoghamba Vibhag Nagrik Sahakari Bank Ltd', 'BNK'),
(466, 'Unjha Nagarik Sahakari Bank Ltd', 'BNK'),
(467, 'The Godhra City Co-operative Bank Ltd', 'BNK'),
(468, 'Shree Bharat Co-operative Bank Ltd', 'BNK'),
(469, 'Gujarat Mercantile Co-operative Bank Ltd', 'BNK'),
(470, 'Navsarjan Industrial Co-operative Bank Ltd.', 'BNK'),
(471, 'Amarnath Co-operative Bank Ltd', 'BNK'),
(472, 'Progressive Mercantile Co-op Bank Ltd.', 'BNK'),
(473, 'SARVODAY NAGARIK SAHAKARI BANK LTD.,HIMMATNAGAR', 'BNK'),
(474, 'THE KARAD URBAN CO-OP. BANK LTD.', 'BNK'),
(475, 'SARDAR VALLABHBHAI SAHAKARI BANK LTD', 'BNK'),
(476, 'Ypay Card', 'WLT'),
(477, 'THE MUNICIPAL CO-OP BANK LTD, MUMBAI.', 'BNK'),
(478, 'THE IDAR NAGRIK SAHAKARI BANK LTD', 'BNK'),
(479, 'BHOPAL CO-OPERATIVE CENTRAL BANK LTD', 'BNK'),
(480, 'Agroha Cooperative urban Bank Limited', 'BNK'),
(481, 'AMBAJOGAI PEOPLES CO OP BANK LTD', 'BNK'),
(482, 'THE BAGHAT URBAN CO-OP. BANK LTD', 'BNK'),
(483, 'BALITIKURI CO-OPERATIVE BANK LTD.', 'BNK'),
(484, 'ADARNIYA P. D. PATILSAHEB SAHAKARI BANK LTD', 'BNK'),
(485, 'SHREE VARDHAMAN SAHAKARI BANK LIMITED', 'BNK'),
(486, 'AHMEDNAGAR SHAHAR SAHAKARI BANK MARYADIT, AHMEDNAGAR', 'BNK'),
(487, 'THE BHAWANIPATNA CENTRAL COOPERATIVE BANK LTD.', 'BNK'),
(488, 'RapiPay Fintech Pvt. Ltd', 'WLT'),
(489, 'Shree Bhavnagar Nagrik Sahakari Bank Ltd.', 'BNK'),
(490, 'THE GOA STATE COOPERATIVE BANK LTD', 'BNK'),
(491, 'BHADRADRI CO OPERATIVE URBAN BANK LTD', 'BNK'),
(492, 'BALOTRA URBAN CO OPERATIVE BANK LTD.', 'BNK'),
(493, 'The Kachchh District Central Co-Operative Bank Ltd', 'BNK'),
(494, 'The Banaskantha Mercantile Co. Operative Bank Ltd', 'BNK'),
(495, 'The Surat Mercantile Co-op. Bank Ltd.', 'BNK'),
(496, 'The Bhuj Mercantile Co-operative Bank Ltd.', 'BNK'),
(497, 'The Karnataka State Co-Operative Apex Bank Ltd.', 'BNK'),
(498, 'Decfin Tech Private Limited ( Decentro)', 'WLT'),
(499, 'Bombay Mercantile Co-operative Bank Ltd.', 'BNK'),
(500, 'The Nilambur Co operative Urban Bank Ltd.', 'BNK'),
(501, '1Pay Mobileware Private Ltd.', 'WLT'),
(502, 'The Maharashtra State Co-op. Bank Ltd.', 'BNK'),
(503, 'KEB Hana Bank Ltd.', 'BNK'),
(504, 'THE SARVODAYA SAHAKARI BANK LTD.', 'BNK'),
(505, 'THE WEST BENGAL STATE CO. OPERATIVE BANK', 'BNK'),
(506, 'LivQuik Technology (lndia) Pvt. Ltd', 'WLT'),
(507, 'I Money Pay Pvt. Ltd.(EMeditek Global Pvt. Ltd.)', 'WLT'),
(508, 'Manjeri Co-Operative Urban Bank Ltd', 'BNK'),
(509, 'The Wardha District Central Co-operative Bank Ltd.', 'BNK'),
(510, 'Western Union Financial Services', 'WLT'),
(511, 'THE DAMAN AND DIU STATE CO-OPERATIVE BANK LTD.', 'BNK'),
(512, 'Jogindra Central Cooperative Bank Ltd', 'BNK'),
(513, 'Slice ( GaragePreneurs Internet Pvt Ltd )', 'WLT'),
(514, 'Tripura Gramin Bank', 'BNK'),
(515, 'Jamia Co-operative Bank', 'BNK'),
(516, 'PPIW0881822 One Mobikwik Systems Pvt Ltd.', 'PPI'),
(517, 'PPIW0882027 PhonePe Pvt. Ltd', 'PPI'),
(518, 'PPIW0882111 Spice Money Pvt Ltd.', 'PPI'),
(519, 'PPIW0884005 IDFC First Bank Pvt Ltd.', 'PPI'),
(520, 'PPIW0884102 Livquik Technology Pvt. Ltd.', 'PPI'),
(521, 'PPIW0886232 Garagepreneur Internet Pvt ltd.', 'PPI'),
(522, 'ICIC0PABCDE ICICI Bank', 'PPI'),
(523, 'PPIW0884308 Hip Bar Private Limited (CRED)', 'PPI'),
(524, 'PPIW0884207 Amazon Pay (India) Pvt. Ltd.', 'PPI'),
(525, 'PPIW0884713 Pine Labs Private Ltd.', 'PPI'),
(526, 'PPIW0884915 Eroute Technologies Pvt. Ltd.', 'PPI'),
(527, 'PPIW0885526 Axis Bank Freecharge', 'PPI'),
(528, 'YESB0PPIUPI Yes Bank Ltd', 'PPI'),
(529, 'State Bank of India (CBDC)', 'CBD'),
(530, 'ICICI Bank (CBDC)', 'CBD'),
(531, 'IDFC First Bank (CBDC)', 'CBD'),
(532, 'Yes Bank (CBDC)', 'CBD'),
(533, 'HDFC Bank (CBDC)', 'CBD'),
(534, 'Union Bank of India (CBDC)', 'CBD'),
(535, 'Bank of Baroda (CBDC)', 'CBD'),
(536, 'Kotak Mahindra Bank (CBDC)', 'CBD'),
(537, 'Canara Bank (CBDC)', 'CBD'),
(538, 'Axis Bank (CBDC)', 'CBD'),
(539, 'IndusInd Bank (CBDC)', 'CBD'),
(540, 'Punjab National Bank (CBDC)', 'CBD'),
(541, 'Federal Bank (CBDC)', 'CBD'),
(542, 'Karnataka Bank Ltd (CBDC)', 'CBD'),
(543, 'Indian Bank (CBDC)', 'CBD'),
(544, 'IDBI Bank (CBDC)', 'CBD'),
(545, 'PayPoint India Network Pvt. Ltd.', 'WLT'),
(546, 'Krumvruddhi Technologies Pvt. Ltd.', 'MER'),
(547, 'NATIONAL BANK FOR AGRICULTURE AND RURAL DEVELOPMENT (NABARD)', 'BNK'),
(548, 'The Assam Co-Operative Apex Bank Ltd. (APEX BANK)', 'BNK');
</sql>
</changeSet>
<!-- IFSC prefixes of the banks whose prefix is certain. A payee IFSC whose prefix is not listed is still reported,
with the IFSC itself; only the I4C bank code is then left at 0 and flagged in remarks. -->
<changeSet author="fineract" id="0611-2-i4c-bank-master-ifsc-prefix">
<sql>
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UTIB' WHERE bank_code = 3;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BBKM' WHERE bank_code = 4;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BARB' WHERE bank_code = 5;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BKID' WHERE bank_code = 7;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'MAHB' WHERE bank_code = 8;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CNRB' WHERE bank_code = 9;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CBIN' WHERE bank_code = 10;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CIUB' WHERE bank_code = 11;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'DEUT' WHERE bank_code = 13;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'DCBL' WHERE bank_code = 14;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'DLXB' WHERE bank_code = 15;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'FDRL' WHERE bank_code = 16;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'ICIC' WHERE bank_code = 17;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IBKL' WHERE bank_code = 18;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IDIB' WHERE bank_code = 19;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IOBA' WHERE bank_code = 20;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'INDB' WHERE bank_code = 21;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'JAKA' WHERE bank_code = 23;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'KARB' WHERE bank_code = 24;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'KVBL' WHERE bank_code = 25;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'DBSS' WHERE bank_code = 27;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'PUNB' WHERE bank_code = 29;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'PSIB' WHERE bank_code = 31;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SIBL' WHERE bank_code = 33;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SBIN' WHERE bank_code = 36;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'TMBL' WHERE bank_code = 41;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UCBA' WHERE bank_code = 42;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UBIN' WHERE bank_code = 43;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'YESB' WHERE bank_code = 46;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'PYTM' WHERE bank_code = 52;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CSBK' WHERE bank_code = 64;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'HDFC' WHERE bank_code = 65;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IDFB' WHERE bank_code = 66;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'KKBK' WHERE bank_code = 67;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'NTBL' WHERE bank_code = 68;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'FINO' WHERE bank_code = 70;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'CITI' WHERE bank_code = 136;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'AIRP' WHERE bank_code = 141;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UJVN' WHERE bank_code = 145;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'JIOP' WHERE bank_code = 146;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'ESFB' WHERE bank_code = 147;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SCBL' WHERE bank_code = 151;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SURY' WHERE bank_code = 153;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'IPOS' WHERE bank_code = 156;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BDBL' WHERE bank_code = 179;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'ESMF' WHERE bank_code = 180;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'UTKS' WHERE bank_code = 184;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SRCB' WHERE bank_code = 210;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'JSFB' WHERE bank_code = 211;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SMCB' WHERE bank_code = 227;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'NKGS' WHERE bank_code = 245;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'BACB' WHERE bank_code = 255;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'HSBC' WHERE bank_code = 265;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'SVCB' WHERE bank_code = 274;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'JSBP' WHERE bank_code = 361;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'TJSB' WHERE bank_code = 372;
UPDATE m_i4c_bank_master SET ifsc_prefix = 'ABHY' WHERE bank_code = 421;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
Automatic customer intimation for I4C holds (RBI draft directions: tell the account holder by the end of the next day).
m_i4c_intimation_channel which channels send the notice automatically, in what order, with what wording;
maintained from the I4C Live > Intimation Settings screen
m_i4c_intimation_log every notice sent, failed or recorded by hand, per hold
Seeded with EMAIL switched on and SMS / WhatsApp off. With no channel switched on (or none that reaches the customer),
the hold waits for an officer to contact the customer and record it by hand, as before.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="0612-1-i4c-intimation-tables">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_intimation_channel"/></not>
</preConditions>
<createTable tableName="m_i4c_intimation_channel">
<column name="channel" type="VARCHAR(20)"><constraints primaryKey="true" nullable="false"/></column>
<column name="enabled" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="priority" type="INT" defaultValueNumeric="1"><constraints nullable="false"/></column>
<column name="subject" type="VARCHAR(300)"/>
<column name="body" type="TEXT"><constraints nullable="false"/></column>
<column name="updated_by" type="VARCHAR(100)"/>
<column name="updated_at" type="TIMESTAMP"/>
</createTable>
<createTable tableName="m_i4c_intimation_log">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="hold_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_intimation_log_hold" references="m_i4c_hold(id)"/></column>
<column name="channel" type="VARCHAR(20)"><constraints nullable="false"/></column>
<!-- AUTO (sent by the system), MANUAL (recorded by an officer), RESEND (an officer asked the system to send) -->
<column name="mode" type="VARCHAR(10)"><constraints nullable="false"/></column>
<!-- SENT, FAILED, SKIPPED_NO_DESTINATION, PROVIDER_NOT_CONFIGURED, RECORDED -->
<column name="status" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="destination_masked" type="VARCHAR(120)"/>
<column name="provider" type="VARCHAR(30)"/>
<column name="provider_reference" type="VARCHAR(200)"/>
<column name="failure_reason" type="VARCHAR(1000)"/>
<column name="actor" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_i4c_intimation_log" indexName="idx_i4c_intimation_log_hold">
<column name="hold_id"/>
</createIndex>
<addColumn tableName="m_i4c_hold">
<column name="auto_intimation_attempts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0612-2-i4c-intimation-channel-seed">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_i4c_intimation_channel</sqlCheck>
</preConditions>
<insert tableName="m_i4c_intimation_channel">
<column name="channel" value="EMAIL"/>
<column name="enabled" valueBoolean="true"/>
<column name="priority" valueNumeric="1"/>
<column name="subject" value="Amount on hold in your account {accountNumber} - cyber fraud complaint {ackNo}"/>
<column name="body" value="Dear {customerName},&#10;&#10;An amount of Rs {amountHeld} has been put on hold in your account {accountNumber} with {bankName}, {branchName}, on {holdDate}. This follows a cyber-fraud complaint (acknowledgement no. {ackNo}) registered on the National Cybercrime Reporting Portal (NCRP / 1930) of the Indian Cyber Crime Coordination Centre (I4C), Ministry of Home Affairs.&#10;&#10;Only the disputed amount is held; the rest of your balance remains available to you.&#10;&#10;If you believe this hold is in error, please visit your branch with your KYC documents and the reason for the credit. The branch will raise a grievance on the NCRP Grievance Redressal Module for the Investigating Officer to review.&#10;&#10;Contact: {grievanceEmail} / {customerCare}&#10;&#10;This is a system-generated message from {bankName}."/>
<column name="updated_by" value="system"/>
<column name="updated_at" valueDate="${current_datetime}"/>
</insert>
<insert tableName="m_i4c_intimation_channel">
<column name="channel" value="SMS"/>
<column name="enabled" valueBoolean="false"/>
<column name="priority" valueNumeric="2"/>
<column name="subject"/>
<column name="body" value="{bankName}: Rs {amountHeld} put on hold in a/c {accountNumber} on {holdDate} on cyber-fraud complaint {ackNo} (NCRP/I4C). Rest of balance usable. Wrong hold? Visit your branch. {customerCare}"/>
<column name="updated_by" value="system"/>
<column name="updated_at" valueDate="${current_datetime}"/>
</insert>
<insert tableName="m_i4c_intimation_channel">
<column name="channel" value="WHATSAPP"/>
<column name="enabled" valueBoolean="false"/>
<column name="priority" valueNumeric="3"/>
<column name="subject"/>
<column name="body" value="{bankName}: Rs {amountHeld} has been put on hold in your account {accountNumber} on {holdDate} following cyber-fraud complaint {ackNo} (NCRP / I4C). The rest of your balance is usable. If this is in error, please visit your branch. Help: {customerCare}"/>
<column name="updated_by" value="system"/>
<column name="updated_at" valueDate="${current_datetime}"/>
</insert>
</changeSet>
<changeSet author="fineract" id="0612-3-i4c-configure-permission">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'CONFIGURE_I4C'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CONFIGURE_I4C"/>
<column name="entity_name" value="I4C"/>
<column name="action_name" value="CONFIGURE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,240 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
The bank's side of the NCRP Grievance Redressal Module (GRM) and Money Restoration Module (MRM). Both are I4C web
portals with no API: officers act there, and these tables keep the CBS record of what was raised, decided and
carried out on the account, with the deadlines of the GRM manual (IO 7 days, investigation / DGRO / SGRO review 15
days each, IO to instruct the bank within 48 hours of a DGRO / SGRO order).
m_i4c_grievance one GRM grievance on a hold: GR id, enhanced due diligence, stage, deadline, decision
m_i4c_grievance_event its history (submitted, VC requested, information sought, decisions, escalations)
m_i4c_restoration one MRM restoration: as beneficiary bank (money held here goes back to the victim) or as
victim bank (restored money received for our customer); decision, documents, bank action,
and the transfer that carried it out
m_i4c_document orders, notices, indemnity bonds, FIRs, KYC and statements attached to the above
Holds can now be released in part (an order names the amount to keep) and restored to the victim; restoration moves
the money out of the suspect account in the same transaction as the release, straight to the victim's account here
or to the bank's restoration pool account for an outward NEFT / IMPS / RTGS.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="0613-1-i4c-hold-amounts">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_i4c_hold" columnName="released_amount"/></not>
</preConditions>
<addColumn tableName="m_i4c_hold">
<column name="released_amount" type="DECIMAL(19,2)" defaultValueNumeric="0"><constraints nullable="false"/></column>
<column name="restored_amount" type="DECIMAL(19,2)" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
<addColumn tableName="m_i4c_hold_action_request">
<!-- RELEASE: amount to release (null = all). RESTORE: amount to send to the victim. -->
<column name="amount" type="DECIMAL(19,2)"/>
<column name="restoration_id" type="BIGINT"/>
<column name="grievance_id" type="BIGINT"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0613-2-i4c-grievance">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_grievance"/></not>
</preConditions>
<createTable tableName="m_i4c_grievance">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="hold_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_grievance_hold" references="m_i4c_hold(id)"/></column>
<!-- GR id the GRM portal gives, e.g. GR003260316001; empty while in draft -->
<column name="grievance_no" type="VARCHAR(40)"/>
<!-- REMOVE_HOLD, UNFREEZE, RESTORE_DIGITAL_SERVICES -->
<column name="customer_request" type="VARCHAR(40)"><constraints nullable="false"/></column>
<column name="customer_reason" type="VARCHAR(2000)"/>
<column name="edd_done" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="edd_kyc_verified" type="BOOLEAN"/>
<column name="edd_source_of_funds" type="VARCHAR(2000)"/>
<column name="edd_customer_statement" type="VARCHAR(2000)"/>
<!-- LOW, MEDIUM, HIGH -->
<column name="edd_risk" type="VARCHAR(10)"/>
<!-- SUPPORT (bank backs the request), NOT_SUPPORT -->
<column name="edd_recommendation" type="VARCHAR(20)"/>
<column name="edd_by" type="VARCHAR(100)"/>
<column name="edd_at" type="TIMESTAMP"/>
<!-- DRAFT, NODAL_REVIEW, WITH_IO, IO_INVESTIGATION, WITH_DGRO, WITH_SGRO, CLOSED -->
<column name="stage" type="VARCHAR(20)"><constraints nullable="false"/></column>
<!-- OPEN, ALLOWED, REJECTED, WITHDRAWN, COURT -->
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="stage_entered_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="due_at" type="TIMESTAMP"/>
<!-- REMOVE_HOLD, UNFREEZE_HOLD_CONTINUES, UNFREEZE_AND_REMOVE_HOLD, PARTIAL_HOLD, REJECTED_CONTINUE, SEEK_INFO -->
<column name="decision" type="VARCHAR(30)"/>
<!-- IO, DGRO, SGRO -->
<column name="decision_level" type="VARCHAR(10)"/>
<column name="decision_keep_amount" type="DECIMAL(19,2)"/>
<column name="decision_order_ref" type="VARCHAR(200)"/>
<column name="decision_date" type="DATE"/>
<column name="decision_remarks" type="VARCHAR(2000)"/>
<column name="compliance_due_at" type="TIMESTAMP"/>
<column name="action_request_id" type="BIGINT"/>
<column name="created_by" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="updated_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_i4c_grievance" indexName="idx_i4c_grievance_hold">
<column name="hold_id"/>
</createIndex>
<createTable tableName="m_i4c_grievance_event">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<column name="grievance_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_i4c_grievance_event" references="m_i4c_grievance(id)"/></column>
<column name="event_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="stage" type="VARCHAR(20)"/>
<column name="remarks" type="VARCHAR(2000)"/>
<column name="actor" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_i4c_grievance_event" indexName="idx_i4c_grievance_event">
<column name="grievance_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0613-3-i4c-restoration">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_restoration"/></not>
</preConditions>
<createTable tableName="m_i4c_restoration">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<!-- BENEFICIARY_BANK (money held here goes back), VICTIM_BANK (restored money comes to our customer) -->
<column name="role" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="hold_id" type="BIGINT"><constraints foreignKeyName="fk_i4c_restoration_hold" references="m_i4c_hold(id)"/></column>
<column name="request_id" type="BIGINT"/>
<column name="acknowledgement_no" type="VARCHAR(30)"><constraints nullable="false"/></column>
<!-- MR id the MRM portal gives, e.g. MR20251208000003 -->
<column name="mrm_request_no" type="VARCHAR(40)"/>
<!-- ATTRIBUTION, PRO_RATA, NOT_POSSIBLE_ALREADY_REFUNDED, NOT_POSSIBLE_NO_BALANCE, NOT_POSSIBLE_OTHERS -->
<column name="basis" type="VARCHAR(40)"/>
<column name="restorable_amount" type="DECIMAL(19,2)"/>
<column name="basis_remarks" type="VARCHAR(1000)"/>
<column name="victim_name" type="VARCHAR(200)"/>
<column name="victim_account_no" type="VARCHAR(40)"/>
<column name="victim_ifsc" type="VARCHAR(11)"/>
<column name="victim_bank_name" type="VARCHAR(200)"/>
<column name="fir_no" type="VARCHAR(100)"/>
<column name="fir_date" type="DATE"/>
<column name="court_order_no" type="VARCHAR(100)"/>
<column name="court_order_date" type="DATE"/>
<column name="court_name" type="VARCHAR(200)"/>
<column name="bnss_notice_no" type="VARCHAR(100)"/>
<column name="bnss_notice_date" type="DATE"/>
<column name="indemnity_bond_received" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="io_name" type="VARCHAR(200)"/>
<column name="io_police_station" type="VARCHAR(200)"/>
<!-- PENDING, COMPLIED, NOT_COMPLIED, REFER_BACK_LEA, REFER_BACK_VICTIM_BANK, CONSENSUS -->
<column name="bank_action" type="VARCHAR(30)" defaultValue="PENDING"><constraints nullable="false"/></column>
<!-- ISSUE_COURT_ORDER, ISSUE_FIR, ISSUE_ORDER_AND_FIR, ISSUE_INDEMNITY_BOND, or free text for the other actions -->
<column name="bank_action_reason" type="VARCHAR(1000)"/>
<!--
ASSESSED (restorable amount worked out), REQUESTED (MRM request received), AWAITING_APPROVAL (RESTORE raised),
IN_POOL (moved to the restoration pool, outward not yet sent or failed), TRANSFER_PENDING (outward sent,
awaiting fund-transfer approval or settlement), RESTORED, CLOSED; for VICTIM_BANK: AWAITED, RECEIVED, CLOSED
-->
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="action_request_id" type="BIGINT"/>
<column name="pool_transfer_id" type="BIGINT"/>
<column name="internal_transfer_id" type="BIGINT"/>
<column name="fund_transfer_ref" type="VARCHAR(40)"/>
<column name="transfer_channel" type="VARCHAR(10)"/>
<column name="transfer_status" type="VARCHAR(20)"/>
<column name="transfer_error" type="VARCHAR(1000)"/>
<column name="utr" type="VARCHAR(40)"/>
<column name="restored_amount" type="DECIMAL(19,2)"/>
<column name="restored_at" type="TIMESTAMP"/>
<column name="mrm_updated" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
<column name="mrm_updated_by" type="VARCHAR(100)"/>
<column name="mrm_updated_at" type="TIMESTAMP"/>
<!-- VICTIM_BANK: money received for our customer, or why none is expected -->
<column name="received_amount" type="DECIMAL(19,2)"/>
<column name="received_utr" type="VARCHAR(40)"/>
<column name="received_on" type="DATE"/>
<column name="received_account_no" type="VARCHAR(40)"/>
<!-- ALREADY_REFUNDED_BANK, ALREADY_REFUNDED_POLICE_NOTICE, ALREADY_REFUNDED_COURT_ORDER, DECLINED_REFUND,
DECLINED_TO_PURSUE, RECEIVED_THROUGH_COURT, NOT_REACHABLE -->
<column name="victim_status" type="VARCHAR(40)"/>
<column name="victim_status_remarks" type="VARCHAR(1000)"/>
<column name="created_by" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="created_at" type="TIMESTAMP"><constraints nullable="false"/></column>
<column name="updated_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_i4c_restoration" indexName="idx_i4c_restoration_hold">
<column name="hold_id"/>
</createIndex>
<createIndex tableName="m_i4c_restoration" indexName="idx_i4c_restoration_ack">
<column name="acknowledgement_no"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0613-4-i4c-document">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_i4c_document"/></not>
</preConditions>
<createTable tableName="m_i4c_document">
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
<!-- HOLD, GRIEVANCE, RESTORATION -->
<column name="owner_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="owner_id" type="BIGINT"><constraints nullable="false"/></column>
<!-- IO_ORDER, COURT_ORDER, GRM_ORDER, BNSS_106_NOTICE, INDEMNITY_BOND, FIR, KYC, APPLICATION, PHOTO,
BANK_STATEMENT, EDD_REPORT, PAN, OTHER -->
<column name="doc_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
<column name="file_name" type="VARCHAR(255)"><constraints nullable="false"/></column>
<column name="content_type" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="size_bytes" type="BIGINT"><constraints nullable="false"/></column>
<column name="sha256" type="VARCHAR(64)"><constraints nullable="false"/></column>
<column name="content" type="BYTEA"><constraints nullable="false"/></column>
<column name="remarks" type="VARCHAR(500)"/>
<column name="uploaded_by" type="VARCHAR(100)"><constraints nullable="false"/></column>
<column name="uploaded_at" type="TIMESTAMP"><constraints nullable="false"/></column>
</createTable>
<createIndex tableName="m_i4c_document" indexName="idx_i4c_document_owner">
<column name="owner_type"/>
<column name="owner_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0613-5-i4c-grm-mrm-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code IN ('MANAGE_I4CGRIEVANCE', 'MANAGE_I4CRESTORATION')</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="MANAGE_I4CGRIEVANCE"/>
<column name="entity_name" value="I4CGRIEVANCE"/>
<column name="action_name" value="MANAGE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="MANAGE_I4CRESTORATION"/>
<column name="entity_name" value="I4CRESTORATION"/>
<column name="action_name" value="MANAGE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.crypto;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class I4cCryptoJsAesTest {
/**
* Produced by OpenSSL itself, not by this class: {@code printf 'I4C-MHA test 123' | openssl enc -aes-256-cbc -md md5
* -a -salt -pass pass:LincPaySecret2026}. CryptoJS.AES.encrypt with a passphrase produces the same format, which is
* the one the I4C specification's samples are in.
*/
private static final String OPENSSL_VECTOR = "U2FsdGVkX1886unOR3Lp4cDqFXcktw3aOt0jGZ+D7W0VpimyHDWYEXo33eGHSdSU";
private static final String PASSPHRASE = "LincPaySecret2026";
@Test
void decryptsWhatOpenSslEncrypted() {
assertEquals("I4C-MHA test 123", I4cCryptoJsAes.decrypt(OPENSSL_VECTOR, PASSPHRASE));
}
@Test
void roundTripsIncludingNonAsciiAndLongJson() {
final String text = "{\"username\":\"bank_ucb\",\"note\":\"रुपये ₹ 1,00,000\",\"pad\":\"" + "x".repeat(5000) + "\"}";
final String encrypted = I4cCryptoJsAes.encrypt(text, PASSPHRASE);
assertTrue(encrypted.startsWith("U2FsdGVkX1"), "must be in the Salted__ format the specification shows");
assertEquals(text, I4cCryptoJsAes.decrypt(encrypted, PASSPHRASE));
}
@Test
void randomSaltMakesEveryEncryptionDifferent() {
assertNotEquals(I4cCryptoJsAes.encrypt("same", PASSPHRASE), I4cCryptoJsAes.encrypt("same", PASSPHRASE));
}
@Test
void fixedSaltIsDeterministic() {
final byte[] salt = "12345678".getBytes(StandardCharsets.US_ASCII);
assertEquals(I4cCryptoJsAes.encrypt("same", PASSPHRASE, salt), I4cCryptoJsAes.encrypt("same", PASSPHRASE, salt));
}
@Test
void wrongPassphraseIsRefusedNotGarbled() {
assertThrows(I4cCryptoJsAes.I4cCryptoException.class, () -> I4cCryptoJsAes.decrypt(OPENSSL_VECTOR, "wrong-passphrase"));
}
@Test
void rejectsValuesNotInTheSaltedFormat() {
assertThrows(I4cCryptoJsAes.I4cCryptoException.class, () -> I4cCryptoJsAes.decrypt("not base64 !!", PASSPHRASE));
assertThrows(I4cCryptoJsAes.I4cCryptoException.class, () -> I4cCryptoJsAes.decrypt("aGVsbG8gd29ybGQgaGVsbG8=", PASSPHRASE));
}
@Test
void recognisesTheSpecificationSamples() {
assertTrue(I4cCryptoJsAes.looksEncrypted("U2FsdGVkX1+xxxxxxxxxxxxxxxxxxx1234567890="));
assertFalse(I4cCryptoJsAes.looksEncrypted("test123"));
assertFalse(I4cCryptoJsAes.looksEncrypted(null));
}
}
@@ -0,0 +1,165 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.data;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class I4cComplaintParserTest {
/**
* The specification's UPI sample, with only its JSON syntax repaired (it has smart quotes and missing commas). Its
* oddities are kept: incidents nested inside instrument, "mode_of _payment" with a space, a string layer, and a
* bank code as a string.
*/
private static final String SPEC_UPI_SAMPLE = """
{
"acknowledgement_no": "31608230000001",
"sub_category": " UPI Related Frauds",
"instrument": {
"requestor": "I4C-MHA",
"payer_bank": "Central Bank of India",
"payer_bank_code": "10",
"mode_of _payment": "Debit/Credit",
"payer_mobile_number": "919798868768",
"payer_account_number": "9987868786777",
"state": "DELHI",
"district": "South Delhi",
"transaction_type": "",
"wallet": "Phonepe",
"incidents": [
{"amount": "35.00", "rrn": "244444444444", "transaction_date": "2023-01-02", "transaction_time": "15:02",
"disputed_amount": "35.00", "layer": "0"},
{"amount": "35.00", "rrn": "244444444445", "transaction_date": "02-01-2023", "transaction_time": "15:02",
"disputed_amount": "20.00", "layer": 1}
]
}
}
""";
@Test
void readsTheSpecificationSampleDespiteItsQuirks() {
final I4cComplaintParser.Result result = I4cComplaintParser.parse(json(SPEC_UPI_SAMPLE));
assertTrue(result.isValid(), () -> String.join("; ", result.errors()));
final I4cComplaint c = result.complaint();
assertEquals("31608230000001", c.acknowledgementNo());
assertEquals("UPI Related Frauds", c.subCategory());
assertEquals("Debit/Credit", c.modeOfPayment());
assertEquals("9987868786777", c.payerAccountNumber());
assertEquals("10", c.payerBankCode());
assertEquals("Phonepe", c.wallet());
assertNull(c.transactionType(), "an empty string is treated as absent");
assertEquals(2, c.incidents().size());
assertEquals(new BigDecimal("35.00"), c.incidents().get(0).amount());
assertEquals(LocalDate.of(2023, 1, 2), c.incidents().get(0).transactionDate());
assertEquals(LocalDate.of(2023, 1, 2), c.incidents().get(1).transactionDate(), "dd-MM-yyyy as in the spec tables");
assertEquals(0, c.incidents().get(0).layer());
assertEquals(1, c.incidents().get(1).layer());
assertEquals(new BigDecimal("20.00"), c.incidents().get(1).disputedAmount());
assertEquals(1, c.maxLayer());
}
@Test
void acceptsTheMisspeltAcknowledgementKeyAndTopLevelIncidents() {
final JsonObject j = json("""
{"acknowledment_no": "31608230000009", "sub_category": "Fraud Call /Vishing",
"instrument": {"payer_account_number": "000123"},
"incidents": [{"amount": 1000, "rrn": "R1", "transaction_date": "2026-10-19", "disputed_amount": 900, "layer": 2}]}
""");
final I4cComplaintParser.Result result = I4cComplaintParser.parse(j);
assertTrue(result.isValid(), () -> String.join("; ", result.errors()));
assertEquals("31608230000009", result.complaint().acknowledgementNo());
assertEquals(2, result.complaint().incidents().get(0).layer());
}
@Test
void readsCardFieldsForCardFraud() {
final JsonObject j = json("""
{"acknowledgement_no": "1", "sub_category": "Debit/Credit Card Fraud/Sim Swap Fraud",
"instrument": {"payer_account_number": "A1", "transaction_type": "Credit Card"},
"incidents": [{"first6digit": "123456", "last4digit": "6789", "cardlength": "16", "amount": "10",
"rrn": "X", "transaction_date": "2026-01-01", "disputed_amount": "10", "layer": "0"}]}
""");
final I4cComplaint.Incident incident = I4cComplaintParser.parse(j).complaint().incidents().get(0);
assertEquals("123456", incident.cardFirst6());
assertEquals("6789", incident.cardLast4());
assertEquals("16", incident.cardLength());
}
@Test
void reportsEveryMissingField() {
final I4cComplaintParser.Result result = I4cComplaintParser.parse(json("""
{"instrument": {}, "incidents": [{"amount": "0", "transaction_date": "31-31-2026", "layer": "x"}]}
"""));
assertFalse(result.isValid());
final String errors = String.join("\n", result.errors());
assertTrue(errors.contains("acknowledgement_no is required"));
assertTrue(errors.contains("sub_category is required"));
assertTrue(errors.contains("payer_account_number is required"));
assertTrue(errors.contains("amount must be greater than zero"));
assertTrue(errors.contains("disputedamount is required"));
assertTrue(errors.contains("rrn is required"));
assertTrue(errors.contains("is not a date"));
assertTrue(errors.contains("is not a whole number"));
}
@Test
void refusesAnEmptyIncidentList() {
final I4cComplaintParser.Result result = I4cComplaintParser
.parse(json("{\"acknowledgement_no\":\"1\",\"sub_category\":\"x\",\"instrument\":{\"payer_account_number\":\"1\"},\"incidents\":[]}"));
assertFalse(result.isValid());
assertTrue(result.errors().contains("incidents must be a non-empty array"));
}
@Test
void refusesAnAccountNumberWithCharactersOutsideTheSpecification() {
final I4cComplaintParser.Result result = I4cComplaintParser.parse(json("""
{"acknowledgement_no": "1", "sub_category": "x", "instrument": {"payer_account_number": "12;DROP"},
"incidents": [{"amount": "1", "rrn": "R", "transaction_date": "2026-01-01", "disputed_amount": "1", "layer": "0"}]}
"""));
assertFalse(result.isValid());
}
@Test
void parsesDateTimesAndOtherDateFormats() {
assertEquals(LocalDate.of(2023, 1, 2), I4cComplaintParser.parseDate("2023-01-02 15:02:00"));
assertEquals(LocalDate.of(2023, 1, 2), I4cComplaintParser.parseDate("2023-01-02T15:02:00"));
assertEquals(LocalDate.of(2023, 1, 2), I4cComplaintParser.parseDate("02/01/2023"));
assertNull(I4cComplaintParser.parseDate("2022-0-2023"));
}
@Test
void normalisesKeys() {
assertEquals("modeofpayment", I4cComplaintParser.normaliseKey("mode_of _payment"));
assertEquals("payerbankcode", I4cComplaintParser.normaliseKey("payer_bank Code"));
}
private static JsonObject json(final String text) {
return JsonParser.parseString(text).getAsJsonObject();
}
}
@@ -0,0 +1,109 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
/** The rules taken from the GRM and MRM user manuals that do not need a database. */
class I4cGrmMrmTest {
private static final BigDecimal FIR_THRESHOLD = new BigDecimal("50000");
@Test
void proRataSharesAddUpToTheAmountExactly() {
final List<BigDecimal> shares = I4cRestorationService.shares(
List.of(new BigDecimal("1000"), new BigDecimal("2000"), new BigDecimal("3000")), new BigDecimal("100.00"));
assertEquals(new BigDecimal("16.66"), shares.get(0));
assertEquals(new BigDecimal("33.33"), shares.get(1));
// The paisa left over goes to the largest complaint.
assertEquals(new BigDecimal("50.01"), shares.get(2));
assertEquals(new BigDecimal("100.00"), shares.stream().reduce(BigDecimal.ZERO, BigDecimal::add));
}
@Test
void proRataWithOneComplaintGivesItAll() {
assertEquals(List.of(new BigDecimal("1234.56")), I4cRestorationService.shares(List.of(new BigDecimal("5000")), new BigDecimal("1234.56")));
}
@Test
void complianceNeedsCourtOrderOrBnssNoticeWithIndemnityBond() {
final Map<String, Object> r = new HashMap<>();
r.put("restorable_amount", new BigDecimal("20000"));
r.put("victim_account_no", "1234567890");
final List<String> none = List.of("COURT_ORDER", "BNSS_106_NOTICE", "INDEMNITY_BOND", "FIR");
assertEquals(2, I4cRestorationService.complianceProblems(r, none, FIR_THRESHOLD).size());
r.put("bnss_notice_no", "106/2026/77");
r.put("indemnity_bond_received", true);
assertEquals(List.of("Attach the BNSS 106 notice.", "Attach the indemnity bond."),
I4cRestorationService.complianceProblems(r, none, FIR_THRESHOLD));
assertTrue(I4cRestorationService.complianceProblems(r, List.of("COURT_ORDER", "FIR"), FIR_THRESHOLD).isEmpty());
}
@Test
void aboveFiftyThousandTheFirIsNeededUnlessThereIsACourtOrder() {
final Map<String, Object> r = new HashMap<>();
r.put("restorable_amount", new BigDecimal("50000.01"));
r.put("victim_account_no", "1234567890");
r.put("bnss_notice_no", "106/2026/78");
r.put("indemnity_bond_received", true);
final List<String> onlyFirMissing = List.of("COURT_ORDER", "FIR");
assertEquals(1, I4cRestorationService.complianceProblems(r, onlyFirMissing, FIR_THRESHOLD).size());
r.put("fir_no", "FIR 12/2026");
assertEquals(List.of("Attach the FIR copy."), I4cRestorationService.complianceProblems(r, onlyFirMissing, FIR_THRESHOLD));
final Map<String, Object> court = new HashMap<>();
court.put("restorable_amount", new BigDecimal("90000"));
court.put("victim_account_no", "1234567890");
court.put("court_order_no", "CO/55");
assertTrue(I4cRestorationService.complianceProblems(court, List.of("FIR", "BNSS_106_NOTICE"), FIR_THRESHOLD).isEmpty());
}
@Test
void documentsMustBeWhatTheySay() {
assertTrue(I4cDocumentService.matchesType("application/pdf", "%PDF-1.7 rest".getBytes(StandardCharsets.US_ASCII)));
assertFalse(I4cDocumentService.matchesType("application/pdf", "MZ\u0090\u0000 not a pdf".getBytes(StandardCharsets.ISO_8859_1)));
assertTrue(I4cDocumentService.matchesType("image/png", new byte[] { (byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10, 0 }));
assertTrue(I4cDocumentService.matchesType("image/jpeg", new byte[] { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0 }));
assertFalse(I4cDocumentService.matchesType("text/html", "<html>".getBytes(StandardCharsets.US_ASCII)));
}
@Test
void fileNamesAreStrippedOfPaths() {
assertEquals("order.pdf", I4cDocumentService.safeName("C:\\Users\\x\\order.pdf"));
assertEquals("passwd", I4cDocumentService.safeName("../../etc/passwd"));
assertEquals("a_b.pdf", I4cDocumentService.safeName("a<b.pdf"));
}
@Test
void eachGrmStageNamesWhoDecides() {
assertEquals("the IO", I4cGrievanceService.expectedLevel("IO_INVESTIGATION"));
assertEquals("the District GRO", I4cGrievanceService.expectedLevel("WITH_DGRO"));
assertEquals("the State GRO", I4cGrievanceService.expectedLevel("WITH_SGRO"));
}
}
@@ -0,0 +1,208 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
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.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
@ExtendWith(MockitoExtension.class)
class I4cHoldServiceTest {
private static final AccountRef SAVINGS = new AccountRef(I4cAccountGateway.SAVINGS, 7L, "000100", 3L, 1L, 300, true);
private static final AccountRef CLOSED = new AccountRef(I4cAccountGateway.SAVINGS, 8L, "000200", 3L, 1L, 600, false);
private static final String FIND_BY_INCIDENT = "SELECT * FROM m_i4c_hold WHERE incident_id = ?";
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private I4cAccountGateway gateway;
@Mock
private PlatformTransactionManager transactionManager;
@Mock
private AppUser maker;
@Mock
private I4cRestorationExecutor restorationExecutor;
private I4cHoldService service;
@BeforeEach
void setUp() {
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null));
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil
.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, LocalDate.now(ZoneId.systemDefault()))));
service = new I4cHoldService(jdbcTemplate, gateway, new I4cProperties(), transactionManager, restorationExecutor);
lenient().when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(99L);
}
@AfterEach
void tearDown() {
ThreadLocalContextUtil.reset();
}
@Test
void holdsTheWholeDisputedAmountWhenTheBalanceCoversIt() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 11L)).thenReturn(List.of());
when(gateway.withdrawable(SAVINGS)).thenReturn(new BigDecimal("10000"));
when(gateway.placeHold(eq(SAVINGS), eq(new BigDecimal("2500")), anyString())).thenReturn(555L);
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 11L, "316", 1, SAVINGS, new BigDecimal("2500"));
assertEquals(new BigDecimal("2500"), outcome.amountHeld());
assertEquals(0, outcome.shortfall().signum());
assertTrue(outcome.holdable());
verify(gateway).placeHold(SAVINGS, new BigDecimal("2500"), "I4C CFCFRMS Ack 316 L1");
}
@Test
void holdsWhatIsThereAndRecordsTheRestAsProspective() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 12L)).thenReturn(List.of());
when(gateway.withdrawable(SAVINGS)).thenReturn(new BigDecimal("600"));
when(gateway.placeHold(eq(SAVINGS), eq(new BigDecimal("600")), anyString())).thenReturn(556L);
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 12L, "316", 2, SAVINGS, new BigDecimal("2500"));
assertEquals(new BigDecimal("600"), outcome.amountHeld());
assertEquals(new BigDecimal("1900"), outcome.shortfall());
}
@Test
void placesNoHoldOnAnEmptyAccountButKeepsTheWholeAmountProspective() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 13L)).thenReturn(List.of());
when(gateway.withdrawable(SAVINGS)).thenReturn(BigDecimal.ZERO);
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 13L, "316", 1, SAVINGS, new BigDecimal("2500"));
assertEquals(0, outcome.amountHeld().signum());
assertEquals(new BigDecimal("2500"), outcome.shortfall());
verify(gateway, never()).placeHold(any(), any(), anyString());
}
@Test
void touchesNothingOnAnAccountThatCannotBeHeld() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 14L)).thenReturn(List.of());
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 14L, "316", 1, CLOSED, new BigDecimal("2500"));
assertFalse(outcome.holdable());
verify(gateway, never()).withdrawable(any());
verify(gateway, never()).placeHold(any(), any(), anyString());
}
@Test
void neverHoldsTwiceForTheSameIncident() {
when(jdbcTemplate.queryForList(FIND_BY_INCIDENT, 15L)).thenReturn(List.of(
Map.of("id", 40L, "amount_held", new BigDecimal("500"), "shortfall", new BigDecimal("100"), "status", I4cHoldService.ACTIVE)));
final I4cHoldService.HoldOutcome outcome = service.placeForIncident(1L, 15L, "316", 1, SAVINGS, new BigDecimal("600"));
assertEquals(40L, outcome.holdId());
assertEquals(new BigDecimal("500"), outcome.amountHeld());
verify(gateway, never()).placeHold(any(), any(), anyString());
}
@Test
void aReleaseNeedsAnOrderExceptAtTheNinetyDayReview() {
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", 40L))
.thenReturn(List.of(Map.of("id", 40L, "status", "ACTIVE", "account_type", "SAVINGS", "debit_frozen", false)));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(40L, "RELEASE", "IO_ORDER", " ", null, null, null, maker));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(40L, "DELETE", "IO_ORDER", "X/1", null, null, null, maker));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(40L, "RELEASE", "BECAUSE", "X/1", null, null, null, maker));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(40L, "UNFREEZE", "IO_ORDER", "X/1", null, null, null, maker));
}
@Test
void aPartialReleaseMustLeaveSomethingWithinWhatIsHeld() {
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", 42L)).thenReturn(List.of(Map.of("id", 42L, "status", "ACTIVE",
"account_type", "SAVINGS", "debit_frozen", false, "amount_held", new BigDecimal("2000.00"))));
assertThrows(PlatformDataIntegrityException.class, () -> service.requestAction(42L, "RELEASE", "IO_ORDER", "X/1", null, null, null,
new BigDecimal("2000.01"), null, null, maker));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(42L, "RELEASE", "IO_ORDER", "X/1", null, null, null, BigDecimal.ZERO, null, null, maker));
assertThrows(PlatformDataIntegrityException.class, () -> service.requestAction(42L, "FREEZE", "IO_ORDER", "X/1", null, null, null,
new BigDecimal("10"), null, null, maker));
}
@Test
void aRestorationNeedsItsMrmRecordAndAmount() {
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", 43L)).thenReturn(List.of(Map.of("id", 43L, "status", "ACTIVE",
"account_type", "SAVINGS", "debit_frozen", false, "amount_held", new BigDecimal("2000.00"))));
assertThrows(PlatformDataIntegrityException.class, () -> service.requestAction(43L, "RESTORE", "MRM_RESTORATION", "BNSS/1", null, null,
null, new BigDecimal("500"), null, null, maker));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(43L, "RESTORE", "MRM_RESTORATION", "BNSS/1", null, null, null, null, 5L, null, maker));
}
@Test
void freezeIsRefusedOnCurrentAccounts() {
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold WHERE id = ?", 41L))
.thenReturn(List.of(Map.of("id", 41L, "status", "ACTIVE", "account_type", "CURRENT", "debit_frozen", false)));
assertThrows(PlatformDataIntegrityException.class,
() -> service.requestAction(41L, "FREEZE", "LEA_DIRECTION", "X/1", null, null, null, maker));
}
@Test
void theMakerCannotApproveTheirOwnRequest() {
when(jdbcTemplate.queryForList("SELECT * FROM m_i4c_hold_action_request WHERE id = ?", 5L))
.thenReturn(List.of(Map.of("id", 5L, "status", "PENDING", "maker_id", 21L, "hold_id", 40L, "action", "RELEASE")));
when(maker.getId()).thenReturn(21L);
assertThrows(PlatformDataIntegrityException.class, () -> service.approveAction(5L, maker));
verify(gateway, never()).releaseHold(any(), anyLong());
}
}
@@ -0,0 +1,121 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.apache.fineract.portfolio.cfcfrms.i4c.config.I4cProperties;
import org.apache.fineract.portfolio.cfcfrms.i4c.crypto.I4cCryptoJsAes;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@ExtendWith(MockitoExtension.class)
class I4cInboundAuthServiceTest {
@Mock
private JdbcTemplate jdbcTemplate;
private I4cProperties properties;
private I4cInboundAuthService service;
@BeforeEach
void setUp() {
properties = new I4cProperties();
properties.getInbound().setUsername("i4c-bank");
properties.getInbound().setPassword("s3cret");
properties.getCrypto().setPassphrase("phrase");
service = new I4cInboundAuthService(properties, jdbcTemplate);
}
@Test
void issuesATokenForTheConfiguredCredentialsAndStoresOnlyItsHash() {
final Optional<I4cInboundAuthService.IssuedToken> token = service.issueToken("i4c-bank", "s3cret", "10.0.0.5");
assertTrue(token.isPresent());
final ArgumentCaptor<Object> args = ArgumentCaptor.forClass(Object.class);
verify(jdbcTemplate).update(anyString(), args.capture(), args.capture(), args.capture(), args.capture(), args.capture());
final String storedHash = (String) args.getAllValues().get(0);
assertEquals(I4cInboundAuthService.sha256(token.get().accessToken()), storedHash);
assertNotEquals(token.get().accessToken(), storedHash);
assertEquals("10.0.0.5", args.getAllValues().get(1));
}
@Test
void acceptsCredentialsEncryptedTheWayTheSpecificationShows() {
final String user = I4cCryptoJsAes.encrypt("i4c-bank", "phrase");
final String pass = I4cCryptoJsAes.encrypt("s3cret", "phrase");
assertTrue(service.issueToken(user, pass, "10.0.0.5").isPresent());
}
@Test
void refusesWrongCredentials() {
assertTrue(service.issueToken("i4c-bank", "wrong", "10.0.0.5").isEmpty());
assertTrue(service.issueToken("someone", "s3cret", "10.0.0.5").isEmpty());
assertTrue(service.issueToken(null, null, "10.0.0.5").isEmpty());
verify(jdbcTemplate, never()).update(anyString(), any(), any(), any(), any(), any());
}
@Test
void prefersTheBcryptHashOverThePlainPassword() {
properties.getInbound().setPasswordHash(new BCryptPasswordEncoder().encode("hashed-secret"));
assertTrue(service.issueToken("i4c-bank", "hashed-secret", "1.1.1.1").isPresent());
assertTrue(service.issueToken("i4c-bank", "s3cret", "1.1.1.1").isEmpty());
}
@Test
void refusesEverythingWhenNoCredentialsAreConfigured() {
properties.getInbound().setUsername("");
assertTrue(service.issueToken("", "s3cret", "1.1.1.1").isEmpty());
}
@Test
void validatesOnlyBearerTokensThatExist() {
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), any(), any())).thenReturn(1);
assertTrue(service.isValidToken("Bearer abc"));
assertTrue(service.isValidToken("bearer abc"));
assertFalse(service.isValidToken("Basic abc"));
assertFalse(service.isValidToken(null));
assertFalse(service.isValidToken("Bearer "));
}
@Test
void enforcesTheIpAllowlistWhenOneIsConfigured() {
assertTrue(service.isIpAllowed("8.8.8.8"), "no allowlist: UAT mode");
properties.getInbound().setAllowedIps("164.100.1.1, 164.100.1.2");
assertTrue(service.isIpAllowed("164.100.1.2"));
assertFalse(service.isIpAllowed("8.8.8.8"));
assertFalse(service.isIpAllowed(null));
}
}
@@ -0,0 +1,115 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.notification.CustomerNotificationDispatcher;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
@ExtendWith(MockitoExtension.class)
class I4cIntimationServiceTest {
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private CustomerNotificationDispatcher dispatcher;
@Mock
private ClientRepositoryWrapper clientRepository;
@Mock
private EmailSenderService emailSender;
@Mock
private I4cBankDirectory banks;
@Mock
private PlatformTransactionManager transactionManager;
private I4cIntimationService service;
@BeforeEach
void setUp() {
service = new I4cIntimationService(jdbcTemplate, dispatcher, clientRepository, emailSender, banks, transactionManager);
}
@Test
void fillsEveryPlaceholder() {
final String out = I4cIntimationService.render("Dear {customerName}, Rs {amountHeld} on {accountNumber} ({ackNo}). {unknown}",
Map.of("customerName", "Anshul", "amountHeld", "1500.00", "accountNumber", "XXXX0006", "ackNo", "316"));
assertEquals("Dear Anshul, Rs 1500.00 on XXXX0006 (316). {unknown}", out);
assertEquals("", I4cIntimationService.render(null, Map.of()));
}
@Test
void masksAllButTheLastFourDigits() {
assertEquals("XXXXXXXXXXX0006", I4cIntimationService.maskAccount("002001010100006"));
assertEquals("1234", I4cIntimationService.maskAccount("1234"));
assertEquals("", I4cIntimationService.maskAccount(null));
}
@Test
void leavesTheHoldForAnOfficerWhenNoChannelIsSwitchedOn() {
lenient().when(jdbcTemplate.queryForList(eq("SELECT * FROM m_i4c_hold WHERE id = ?"), eq(5L))).thenReturn(List.of(Map.of("id", 5L,
"amount_held", new BigDecimal("1500"), "status", "ACTIVE", "client_id", 3L, "acknowledgement_no", "316")));
lenient().when(jdbcTemplate.queryForList(anyString(), eq(String.class))).thenReturn(List.of());
final I4cIntimationService.Result result = service.sendNow(5L, "officer");
assertEquals(I4cIntimationService.Outcome.NO_CHANNEL_ENABLED, result.outcome());
verify(dispatcher, never()).dispatch(any(), any(), anyString(), anyString(), any());
}
@Test
void doesNotTellTwice() {
lenient().when(jdbcTemplate.queryForList(eq("SELECT * FROM m_i4c_hold WHERE id = ?"), eq(6L))).thenReturn(List.of(Map.of("id", 6L,
"amount_held", new BigDecimal("1500"), "status", "ACTIVE", "customer_intimated_at", new java.sql.Timestamp(0),
"intimation_mode", "EMAIL")));
assertEquals(I4cIntimationService.Outcome.ALREADY_INTIMATED, service.sendNow(6L, "officer").outcome());
}
@Test
void refusesToMakePhoneOrLetterAutomatic() {
assertThrows(PlatformDataIntegrityException.class, () -> service.updateChannel("PHONE", true, 1, null, "x", "officer"));
assertThrows(PlatformDataIntegrityException.class, () -> service.updateChannel("EMAIL", true, 1, " ", "x", "officer"));
assertThrows(PlatformDataIntegrityException.class, () -> service.updateChannel("EMAIL", true, 0, null, null, "officer"));
}
@Test
void testMessagesAreEmailOnlyAndNeedAnAddress() {
assertThrows(PlatformDataIntegrityException.class, () -> service.sendTest("SMS", "a@b.in"));
assertThrows(PlatformDataIntegrityException.class, () -> service.sendTest("EMAIL", "not-an-address"));
}
}
@@ -0,0 +1,72 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.junit.jupiter.api.Test;
class I4cListQueryTest {
@Test
void defaultsWhenNothingIsGiven() {
final I4cListQuery q = I4cListQuery.of(Map.of(), 20);
assertEquals(0, q.offset());
assertEquals(20, q.limit());
assertNull(q.fromStart());
assertNull(q.toEndExclusive());
assertNull(q.text("status"));
assertFalse(q.flag("failures"));
}
@Test
void dateRangeCoversTheWholeToDay() {
final I4cListQuery q = I4cListQuery.of(Map.of("fromDate", List.of("2026-10-20"), "toDate", List.of("2026-10-22")), 20);
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 10, 20, 0, 0)), q.fromStart());
assertEquals(Timestamp.valueOf(LocalDateTime.of(2026, 10, 23, 0, 0)), q.toEndExclusive());
}
@Test
void pageIsClampedAndFiltersTrimmed() {
final I4cListQuery q = I4cListQuery.of(Map.of("offset", List.of("-5"), "limit", List.of("100000"), "status", List.of(" sent "),
"failures", List.of("TRUE"), "search", List.of(" ")), 20);
assertEquals(0, q.offset());
assertEquals(I4cListQuery.MAX_LIMIT, q.limit());
assertEquals("SENT", q.upper("status"));
assertTrue(q.flag("failures"));
assertNull(q.text("search"));
}
@Test
void rejectsBadDatesAndReversedRanges() {
assertThrows(PlatformDataIntegrityException.class, () -> I4cListQuery.of(Map.of("fromDate", List.of("22-10-2026")), 20));
assertThrows(PlatformDataIntegrityException.class,
() -> I4cListQuery.of(Map.of("fromDate", List.of("2026-10-22"), "toDate", List.of("2026-10-21")), 20));
assertThrows(PlatformDataIntegrityException.class, () -> I4cListQuery.of(Map.of("limit", List.of("abc")), 20));
}
}
@@ -0,0 +1,124 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
class I4cResponseBuilderTest {
private static final I4cResponseBuilder.Root LAYER1 = new I4cResponseBuilder.Root("00000033082900137", "244444444444", 548, 1,
new BigDecimal("500"), new BigDecimal("1234.5"), "9876543210", "a@b.in", "ABCDE1234F", "LINC0000001");
private static final I4cResponseBuilder.Root LAYER0 = new I4cResponseBuilder.Root("9987868786777", "244444444444", 548, 0,
new BigDecimal("35"), null, null, null, null, null);
@Test
void putOnHoldHasTheSpecificationFields() {
final JsonObject e = I4cResponseBuilder.putOnHold(LAYER1, new BigDecimal("500"), "2026-10-20 10:00:00", "held");
assertEquals("Transaction Put on Hold", e.get("txn_type").getAsString());
assertEquals("1", e.get("txn_type_id").getAsString());
assertEquals("500.00", e.get("amount").getAsString());
assertEquals("00000033082900137", e.get("root_account_number").getAsString());
assertEquals("244444444444", e.get("root_rrn_transaction_id").getAsString());
assertEquals("548", e.get("root_bankid").getAsString());
assertEquals("1234.50", e.get("root_effective_balance").getAsString());
assertEquals("LINC0000001", e.get("root_ifsc_code").getAsString());
assertEquals("9876543210", e.get("phone_number").getAsString());
assertEquals("ABCDE1234F", e.get("pan_number").getAsString());
assertEquals("00", e.get("status_code").getAsString());
assertTrue(e.has("transaction_datetime") && e.has("ifsc_code") && e.has("email") && e.has("remarks"));
}
@Test
void moneyTransferToUsesTheSampleKeyRrnTransactionId() {
final JsonObject e = I4cResponseBuilder.moneyTransferTo(LAYER1, "UTR123", "State Bank of India", 36, "1111", "SBIN0000625",
new BigDecimal("400"), new BigDecimal("500"), "2026-10-20 09:00:00", "Outward IMPS");
assertEquals("3", e.get("txn_type_id").getAsString());
assertEquals("Money Transfer To", e.get("txn_type").getAsString());
assertEquals("UTR123", e.get("rrn_transaction_id").getAsString());
assertEquals("36", e.get("payee_bank_code").getAsString());
assertEquals("1111", e.get("payee_account_number").getAsString());
assertEquals("SBIN0000625", e.get("ifsc_code").getAsString());
assertEquals("400.00", e.get("amount").getAsString());
assertEquals("500.00", e.get("disputed_amount").getAsString());
}
@Test
void layerZeroUsesTheSpecificationDefaults() {
final JsonObject e = I4cResponseBuilder.moneyTransferTo(LAYER0, "UTR1", "SBI", 36, "1", "SBIN0000001", new BigDecimal("35"),
new BigDecimal("35"), "2023-01-02 15:02:00", "");
assertEquals("0000000000", e.get("phone_number").getAsString());
assertEquals("Layer 0", e.get("pan_number").getAsString());
assertEquals("0", e.get("root_effective_balance").getAsString());
assertEquals("Layer 0", e.get("root_ifsc_code").getAsString());
}
@Test
void chequeAndOthersCarryTheirTypes() {
final JsonObject cheque = I4cResponseBuilder.withdrawalThroughCheque(LAYER1, "000123", "2026-10-20 11:00:00", new BigDecimal("300"),
new BigDecimal("500"), "Main Branch", "Branch Manager, Main Branch", "0221234567", "Cheque withdrawal");
assertEquals("14", cheque.get("txn_type_id").getAsString());
assertEquals("000123", cheque.get("cheque_no").getAsString());
assertEquals("00000033082900137", cheque.get("account_number").getAsString());
final JsonObject others = I4cResponseBuilder.othersUpTo500(LAYER1, new BigDecimal("200"), "small");
assertEquals("17", others.get("txn_type_id").getAsString());
assertEquals("Others Less than equal to 500", others.get("txn_type").getAsString());
}
@Test
void failureUsesStatusCodesOneAndTwo() {
final JsonObject notFound = I4cResponseBuilder.failure("X", "R", 548, BigDecimal.TEN, BigDecimal.ONE, "", "01", "Record not found");
assertEquals("01", notFound.get("status_code").getAsString());
assertEquals("10.00", notFound.get("amount").getAsString());
assertEquals("1.00", notFound.get("disputed_amount").getAsString());
}
@Test
void envelopeCarriesAckAndJobId() {
final JsonObject env = I4cResponseBuilder.envelope("316", "BANKS-1", List.of(new JsonObject(), new JsonObject()));
assertEquals("316", env.get("acknowledgement_no").getAsString());
assertEquals("BANKS-1", env.get("job_id").getAsString());
assertEquals(2, env.getAsJsonArray("transactions").size());
}
@Test
void reportedDateTimeIsPadded() {
assertEquals("2023-01-02 15:02:00", I4cComplaintProcessor.reportedDateTime(LocalDate.of(2023, 1, 2), "15:02"));
assertEquals("2023-01-02 09:05:07", I4cComplaintProcessor.reportedDateTime(LocalDate.of(2023, 1, 2), "9:05:07"));
assertEquals("2023-01-02 00:00:00", I4cComplaintProcessor.reportedDateTime(LocalDate.of(2023, 1, 2), "garbage"));
assertEquals("", I4cComplaintProcessor.reportedDateTime(null, "10:00"));
}
@Test
void apiLogMasksSecretsAndPan() {
final String masked = I4cApiLogService
.mask("{\"username\":\"bank\",\"password\":\"p@ss\",\"access_token\":\"abc\",\"pan_number\":\"ABCDE1234F\"}");
assertTrue(masked.contains("\"username\":\"***\""));
assertTrue(masked.contains("\"password\":\"***\""));
assertTrue(masked.contains("\"access_token\":\"***\""));
assertTrue(masked.contains("ABCDE****F"));
}
}
@@ -0,0 +1,185 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cfcfrms.i4c.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.Base64;
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.I4cComplaintParser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
/** The parts of the I4C-MHA specification 2.0 beyond the basic flow: section 2 auth forms, section 7 codes, section 6 types. */
@ExtendWith(MockitoExtension.class)
class I4cSpecCoverageTest {
@Mock
private JdbcTemplate jdbcTemplate;
private I4cProperties properties;
private I4cInboundAuthService auth;
@BeforeEach
void setUp() {
properties = new I4cProperties();
properties.getInbound().setUsername("i4c-bank");
properties.getInbound().setPassword("s3cret");
properties.getCrypto().setPassphrase("phrase");
auth = new I4cInboundAuthService(properties, jdbcTemplate);
}
// ── Section 2: "Basic Auth / Auth 2.0", credentials "in every request" ──
@Test
void acceptsBasicAuthHeaders() {
assertTrue(auth.isValidBasic(basic("i4c-bank", "s3cret")));
assertTrue(auth.isValidBasic(basic(I4cCryptoJsAes.encrypt("i4c-bank", "phrase"), I4cCryptoJsAes.encrypt("s3cret", "phrase"))));
assertFalse(auth.isValidBasic(basic("i4c-bank", "wrong")));
assertFalse(auth.isValidBasic("Basic not-base64!!"));
assertFalse(auth.isValidBasic("Bearer abc"));
assertNull(I4cInboundAuthService.basicCredentials(null));
}
@Test
void acceptsCredentialsInsideTheMessageAndRemovesThem() {
final JsonObject body = json("{\"username\":\"i4c-bank\",\"password\":\"s3cret\",\"acknowledgement_no\":\"1\"}");
assertEquals(I4cProperties.AuthMode.BODY, auth.authenticatePush(null, body));
assertFalse(body.has("username"), "credentials must not stay in the stored message");
assertFalse(body.has("password"));
assertTrue(body.has("acknowledgement_no"));
}
@Test
void acceptsBase64AndEncryptedAndClientIdForms() {
final String b64User = Base64.getEncoder().encodeToString("i4c-bank".getBytes(StandardCharsets.UTF_8));
final String b64Pass = Base64.getEncoder().encodeToString("s3cret".getBytes(StandardCharsets.UTF_8));
assertTrue(auth.credentialsMatch(b64User, b64Pass), "specification text: Base64-encoded");
assertTrue(auth.credentialsMatch(I4cCryptoJsAes.encrypt("i4c-bank", "phrase"), I4cCryptoJsAes.encrypt("s3cret", "phrase")),
"specification samples: U2FsdGVkX1...");
assertEquals(I4cProperties.AuthMode.BODY,
auth.authenticatePush(null, json("{\"client_id\":\"i4c-bank\",\"client_secret\":\"s3cret\"}")));
assertFalse(auth.credentialsMatch("i4c-bank", Base64.getEncoder().encodeToString("wrong".getBytes(StandardCharsets.UTF_8))));
}
@Test
void honoursTheConfiguredAuthModes() {
properties.getInbound().setAuthModes("TOKEN");
assertNull(auth.authenticatePush(basic("i4c-bank", "s3cret"), json("{\"username\":\"i4c-bank\",\"password\":\"s3cret\"}")));
properties.getInbound().setAuthModes("TOKEN,BASIC");
assertEquals(I4cProperties.AuthMode.BASIC, auth.authenticatePush(basic("i4c-bank", "s3cret"), null));
}
@Test
void readsBasicCredentialsWithAColonInThePassword() {
assertArrayEquals(new String[] { "u", "p:a:ss" }, I4cInboundAuthService.basicCredentials(basic("u", "p:a:ss")));
}
// ── Section 7 codes ──
@Test
void reportsTheSpecificSection7Code() {
final LocalDate today = LocalDate.of(2026, 10, 22);
assertEquals("40005", I4cComplaintParser.parse(complaint("000123", "100", "2026-10-30"), today, 1).section7Code(),
"future date: 40005 Invalid Date");
assertEquals("40001", I4cComplaintParser.parse(complaint("12;DROP", "100", "2026-10-20"), today, 1).section7Code(),
"40001 Invalid Account Number");
assertEquals("40004", I4cComplaintParser.parse(complaint("000123", "0", "2026-10-20"), today, 1).section7Code(),
"40004 Invalid Transaction Amount");
assertTrue(I4cComplaintParser.parse(complaint("000123", "100", "2026-10-23"), today, 1).isValid(), "one day ahead is tolerated");
assertTrue(I4cComplaintParser.parse(complaint("000123", "100", "2030-01-01")).isValid(), "no date check without a business date");
assertEquals("11", I4cComplaintParser.parse(json("{\"instrument\":{\"payer_account_number\":\"1\"}}")).section7Code());
}
@Test
void ackCarriesTheSection7Wording() {
final JsonObject ack = json(I4cAcks.ack("50003", "316", "BANKS-1"));
assertEquals("50003", ack.getAsJsonObject("meta").get("response_code").getAsString());
assertEquals("Duplicate Transaction", ack.getAsJsonObject("meta").get("response_message").getAsString());
assertEquals("BANKS-1", ack.getAsJsonObject("data").get("job_id").getAsString());
assertEquals("Invalid Date", I4cAcks.messageFor("40005"));
}
// ── Section 6 types and keys ──
private static final I4cResponseBuilder.Root ROOT = new I4cResponseBuilder.Root("0001", "RRN1", 999, 1, new BigDecimal("500"),
new BigDecimal("700"), "9876543210", "a@b.in", "ABCDE1234F", "LINC0000001");
@Test
void moneyTransferCarriesBothRrnKeys() {
final JsonObject e = I4cResponseBuilder.moneyTransferTo(ROOT, "UTR9", "SBI", 36, "11", "SBIN0000001", BigDecimal.TEN,
BigDecimal.TEN, "2026-10-22 10:00:00", "");
assertEquals("UTR9", e.get("rrn").getAsString());
assertEquals("UTR9", e.get("rrn_transaction_id").getAsString());
}
@Test
void atmAepsAndPosEntriesFollowTheSpecification() {
final JsonObject atm = I4cResponseBuilder.withdrawalThroughAtm(ROOT, new BigDecimal("400"), "2026-10-22 10:00:00", "ATM01",
"MG Road", "SBI", "");
assertEquals("5", atm.get("txn_type_id").getAsString());
assertEquals("Withdrawal through ATM", atm.get("txn_type").getAsString());
assertEquals("ATM01", atm.get("atm_id").getAsString());
assertTrue(atm.has("place_of_atm") && atm.has("atm_of_bank") && atm.has("disputed_amount"));
final JsonObject aeps = I4cResponseBuilder.withdrawalThroughAeps(ROOT, "4306", new BigDecimal("400"), "");
assertEquals("6", aeps.get("txn_type_id").getAsString());
assertEquals("4306", aeps.get("rrn_transaction_id").getAsString());
assertEquals("4306", aeps.get("rrn").getAsString());
final JsonObject pos = I4cResponseBuilder.withdrawalThroughPos(ROOT, new BigDecimal("400"), "2026-10-22 10:00:00", "M1", "T1",
"5659", "Shop", "P1", "");
assertEquals("11", pos.get("txn_type_id").getAsString());
assertEquals("M1", pos.get("mid").getAsString());
assertEquals("T1", pos.get("tid").getAsString());
assertEquals("5659", pos.get("approval_code").getAsString());
assertEquals("Shop", pos.get("merchant_name").getAsString());
assertEquals("P1", pos.get("pos_transaction_id").getAsString());
assertEquals("700.00", pos.get("root_effective_balance").getAsString());
}
private static JsonObject complaint(final String account, final String amount, final String date) {
return json("{\"acknowledgement_no\":\"316\",\"sub_category\":\"UPI Related Frauds\",\"instrument\":{\"payer_account_number\":\""
+ account + "\"},\"incidents\":[{\"amount\":\"" + amount + "\",\"rrn\":\"R1\",\"transaction_date\":\"" + date
+ "\",\"disputed_amount\":\"" + amount + "\",\"layer\":\"1\"}]}");
}
private static String basic(final String user, final String pass) {
return "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(StandardCharsets.UTF_8));
}
private static JsonObject json(final String text) {
return JsonParser.parseString(text).getAsJsonObject();
}
}
@@ -39,6 +39,13 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult depositToFDAccount(Long savingsId, JsonCommand command);
/**
* Recomputes a fixed deposit's maturity date and amount from its real transaction history. Used after an FD
* is funded by a direct book credit (clerk Bank Transfer / Fund Transfer / Disbursement), which does not go
* through {@link #depositToFDAccount}.
*/
void recalculateFixedDepositMaturity(Long savingsId);
CommandProcessingResult depositToRDAccount(Long savingsId, JsonCommand command);
CommandProcessingResult withdrawal(Long savingsId, JsonCommand command, DepositAccountType depositAccountType);
@@ -81,6 +81,15 @@ public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFil
@Setter
private RequestMatcher requestMatcher = AnyRequestMatcher.INSTANCE;
/**
* Tenant used for the I4C (NCRP-CFCFRMS) server-to-server endpoints when the caller sends no tenant header. I4C
* calls the bank exactly as its API specification describes and has no notion of a Fineract tenant, so a bank
* deploying the integration names its tenant once in configuration ({@code fineract.i4c.tenant-identifier}). Blank
* leaves those endpoints under the ordinary rule: the header is required.
*/
@Setter
private String i4cDefaultTenantIdentifier;
public TenantAwareBasicAuthenticationFilter(final AuthenticationManager authenticationManager,
final AuthenticationEntryPoint authenticationEntryPoint, ToApiJsonSerializer<PlatformRequestLog> toApiJsonSerializer,
ConfigurationDomainService configurationDomainService, CacheWritePlatformService cacheWritePlatformService,
@@ -156,6 +165,11 @@ public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFil
tenantIdentifier = request.getParameter("tenantIdentifier");
}
if (org.apache.commons.lang3.StringUtils.isBlank(tenantIdentifier) && isI4cInboundPath(request)
&& org.apache.commons.lang3.StringUtils.isNotBlank(i4cDefaultTenantIdentifier)) {
tenantIdentifier = i4cDefaultTenantIdentifier;
}
if (tenantIdentifier == null && EXCEPTION_IF_HEADER_MISSING) {
throw new InvalidTenantIdentifierException("No tenant identifier found: Add request header of '"
+ TENANT_ID_REQUEST_HEADER + "' or add the parameter 'tenantIdentifier' to query string of request URL.");
@@ -233,6 +247,12 @@ public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFil
return exempt;
}
/** The endpoints I4C itself calls (token and complaint push), plus the local I4C simulator. Not the officer screens. */
private static boolean isI4cInboundPath(final HttpServletRequest request) {
final String uri = request.getRequestURI();
return uri != null && (uri.contains("/i4c/auth/") || uri.endsWith("/i4c/complaints") || uri.contains("/i4c-sim/"));
}
@Override
protected void onSuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authResult)
throws IOException {