Compare commits

..
Author SHA1 Message Date
anshul 4522f1305d feat(investment): FD with other banks - placement, maturity, premature, renewal, interest and RBI limits
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-1) (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 - 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
2026-10-16 17:12:40 +05:30
anshul ac563badaa fix(investment): pre-merge review fixes for script terms, write-off limit, amortisation and concurrent posting
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 E2E Tests / E2E Tests (Shard 8 of 10) (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-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 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 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 - PostgreSQL / test (test-core-3) (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
2026-10-16 12:11:47 +05:30
anshul 861e2e196e feat(investment): sell, redemption, write-off, GOI transfer and premium amortisation
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 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 Documentation build / build (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-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
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
2026-10-15 18:44:52 +05:30
anshul 8ad90f0b00 feat(investment): posted interest in transaction log
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (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 Documentation build / build (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-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-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
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
2026-10-14 17:40:06 +05:30
anshul 5606b88fbc feat(investment): post interest vouchers, due reminders on home and day-end close preview
Fineract E2E Tests / E2E Tests (Shard 10 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 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 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 3 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 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
2026-10-14 15:12:07 +05:30
anshulandClaude Opus 5.5 4bb71382a1 Merge origin/dev-latest into feature/investment
Bring in the latest team changes (FD interest posting approval, recurring
deposit rules, tenant setup, cash exchange PAN, and others).
final-changelog-tenant.xml conflicted because both sides appended
includes: keep the team's includes first, then the investment
0593-0597 includes. File names differ, so the changesets do not clash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:44:29 +05:30
anshulandClaude Opus 5.5 859cabd07d feat(investment): settle purchases to post them to GL on the settlement date
Settlement-date accounting (RBI UCB Investment Directions, confirmed by
Jitendra Sir): a saved purchase is PENDING_SETTLEMENT with nothing in GL.
On or after its settlement date, POST /investment/purchases/{id}/settle
posts the journal entry dated the settlement date at Head Office - Dr
investment GL (book value), Dr 500606 broken-period interest, Dr charge
GLs, Cr the paying bank - through the journal entry command, which also
raises the JV voucher. The deal becomes ACTIVE and keeps the GL
transaction id and settlement day. Settle goes through maker-checker; a
settled deal can no longer be dropped.

Broken-period interest is now counted up to the settlement date instead
of the deal date, and the interest schedule starts from it.

Migration 0597 adds the settlement columns and the SETTLE permission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:28:09 +05:30
anshulandClaude Opus 5.5 6380e0d7d2 feat(investment): broken-period interest as an expense in the interest schedule
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 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 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 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 - PostgreSQL / 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-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
RBI UCB Investment Directions 2025, para 152: broken-period interest paid
to the seller on G-Sec / approved securities is not part of cost but an
expense (confirmed by Jitendra Sir; GL 500606). The interest schedule
therefore no longer carries it as a receivable to be cleared by the first
coupon: the first coupon is income in full. Tests updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:27:07 +05:30
anshulandClaude Opus 5.5 4ad508ee96 feat(investment): Daily Script Master backend
Market rate of each script for a day (open / high / low / close), as the
bank's existing CBS keeps it, so Purchase Entry can take its rate from
here instead of the dealer typing it (Jitendra Sir).

- m_investment_daily_rate, one row per script per day (migration 0596),
  with READ/CREATE/DELETE permissions granted to Investment menu roles.
- /v1/investment/daily-rates: list (optionally per script), save, drop,
  and /latest for the newest rate of a script on or before a date.
- Close rate is mandatory; high may not be below low, and open / close
  must sit inside low-high, with readable messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:27:07 +05:30
anshulandClaude Opus 5.5 6eb562548c feat(investment): keep purchase charges out of book value
Brokerage, stamp duty and the new other charges are expenses of the deal
(Jitendra Sir): they are stored on the purchase and added to the
settlement amount, but no longer to the book value, for securities and
mutual funds alike. Settlement = book value + accrued interest + charges.

Adds m_investment_purchase.other_charges (migration 0595) and the
otherCharges request/response field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:54:59 +05:30
anshulandClaude Opus 5.5 41aeb2b89a Merge origin/dev-latest into feature/investment
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 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 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 Documentation build / build (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 - 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
Bring in the latest team changes (TDS, shares, cashier, RBI reserve).
final-changelog-tenant.xml conflicted because both sides appended after
0592: keep the team's 0592/0593 TDS includes first, then the investment
0593/0594 includes. File names differ, so the changesets do not clash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:22:55 +05:30
anshulandClaude Opus 5.5 2569e0f836 feat(investment): interest schedule API for Interest Entry
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 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 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 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
Add GET /v1/investment/purchases/{id}/interest-schedule. For a holding it
lists every half-yearly coupon and every 31 Mar / 30 Sep closing provision
from purchase to maturity, worked out on the face value still held:
provision = interest earned since the last coupon (30/360), and the coupon
clears what is already booked (purchased accrued interest or the earlier
provision). Lines up to the as-on date are marked due. Calculation only;
nothing is posted.

The day-count and interest arithmetic move into InvestmentInterestCalculator,
shared with Purchase Entry, and are covered by unit tests using Jitendra
Sir's worked example. Duplicate-code errors on the masters now keep the
original exception as the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:45:41 +05:30
anshulandClaude Opus 5.5 81b5850b62 feat(investment): Bank/Party Master, Script Master and Purchase Entry backend
Treasury Investment module, first slice, following the bank's existing CBS
Investment screens:

- Bank / Party Master (m_investment_party): auto party code PTY-nnn, address,
  bank type, GL A/c, RBI code, exposure limit, LEI, counterparty bank details.
- Script Master (m_investment_script): auto script code by bond type
  (G00001, B00001, ...), SLR type (GOI / STATE_GOVT / TREASURY / NON_SLR),
  GL A/c, ISIN, interest rate, issue / maturity dates, issuer details.
- Purchase Entry (m_investment_purchase): one deal = one holding. FY deal
  number (e.g. 202627000001), auto cert no, settlement date from T+n.
  Units, book value, premium/discount, accrued interest (30/360 up to the
  deal date), settlement amount and YTM are computed by the backend.

APIs: /v1/investment/parties, /v1/investment/scripts, /v1/investment/purchases,
all through the command pipeline. Permissions READ/CREATE/UPDATE/DELETE plus
_CHECKER rows are seeded with maker-checker off and granted to roles that
hold MENU_INVESTMENT. Mapped GLs must be enabled ASSET detail accounts.
No GL entry is posted by a purchase yet.

Migrations: 0593_investment_masters, 0594_investment_purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:35:43 +05:30
247 changed files with 11418 additions and 12453 deletions
@@ -80,7 +80,6 @@ public class Charge extends AbstractPersistableCustom<Long> {
@Column(name = "currency_code", length = 3)
private String currencyCode;
@Getter
@Column(name = "charge_applies_to_enum", nullable = false)
private Integer chargeAppliesTo;
@@ -95,6 +95,198 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createInvestmentParty() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTPARTY";
this.entityId = null;
this.href = "/investment/parties";
return this;
}
public CommandWrapperBuilder updateInvestmentParty(final Long partyId) {
this.actionName = "UPDATE";
this.entityName = "INVESTMENTPARTY";
this.entityId = partyId;
this.href = "/investment/parties/" + partyId;
return this;
}
public CommandWrapperBuilder deleteInvestmentParty(final Long partyId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTPARTY";
this.entityId = partyId;
this.href = "/investment/parties/" + partyId;
return this;
}
public CommandWrapperBuilder createInvestmentScript() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTSCRIPT";
this.entityId = null;
this.href = "/investment/scripts";
return this;
}
public CommandWrapperBuilder updateInvestmentScript(final Long scriptId) {
this.actionName = "UPDATE";
this.entityName = "INVESTMENTSCRIPT";
this.entityId = scriptId;
this.href = "/investment/scripts/" + scriptId;
return this;
}
public CommandWrapperBuilder createInvestmentPurchase() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = null;
this.href = "/investment/purchases";
return this;
}
public CommandWrapperBuilder deleteInvestmentPurchase(final Long purchaseId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId;
return this;
}
public CommandWrapperBuilder settleInvestmentPurchase(final Long purchaseId) {
this.actionName = "SETTLE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/settle";
return this;
}
public CommandWrapperBuilder createInvestmentSale() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTSALE";
this.entityId = null;
this.href = "/investment/sales";
return this;
}
public CommandWrapperBuilder settleInvestmentSale(final Long saleId) {
this.actionName = "SETTLE";
this.entityName = "INVESTMENTSALE";
this.entityId = saleId;
this.href = "/investment/sales/" + saleId + "/settle";
return this;
}
public CommandWrapperBuilder deleteInvestmentSale(final Long saleId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTSALE";
this.entityId = saleId;
this.href = "/investment/sales/" + saleId;
return this;
}
public CommandWrapperBuilder createInvestmentFd() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTFD";
this.entityId = null;
this.href = "/investment/fds";
return this;
}
public CommandWrapperBuilder matureInvestmentFd(final Long fdId) {
this.actionName = "MATURE";
this.entityName = "INVESTMENTFD";
this.entityId = fdId;
this.href = "/investment/fds/" + fdId + "/mature";
return this;
}
public CommandWrapperBuilder prematureInvestmentFd(final Long fdId) {
this.actionName = "PREMATURE";
this.entityName = "INVESTMENTFD";
this.entityId = fdId;
this.href = "/investment/fds/" + fdId + "/premature";
return this;
}
public CommandWrapperBuilder renewInvestmentFd(final Long fdId) {
this.actionName = "RENEW";
this.entityName = "INVESTMENTFD";
this.entityId = fdId;
this.href = "/investment/fds/" + fdId + "/renew";
return this;
}
public CommandWrapperBuilder postInvestmentFdInterest(final Long fdId) {
this.actionName = "POSTINTEREST";
this.entityName = "INVESTMENTFD";
this.entityId = fdId;
this.href = "/investment/fds/" + fdId + "/interest";
return this;
}
public CommandWrapperBuilder setInvestmentDepositBase() {
this.actionName = "SETBASE";
this.entityName = "INVESTMENTFD";
this.entityId = null;
this.href = "/investment/fds/deposit-base";
return this;
}
public CommandWrapperBuilder amortiseInvestmentPremium() {
this.actionName = "AMORTISE";
this.entityName = "INVESTMENTSALE";
this.entityId = null;
this.href = "/investment/amortisation";
return this;
}
public CommandWrapperBuilder writeOffInvestmentPurchase(final Long purchaseId) {
this.actionName = "WRITEOFF";
this.entityName = "INVESTMENTSALE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/write-off";
return this;
}
public CommandWrapperBuilder redeemInvestmentPurchase(final Long purchaseId) {
this.actionName = "REDEEM";
this.entityName = "INVESTMENTSALE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/redeem";
return this;
}
public CommandWrapperBuilder transferInvestmentSecurity(final Long purchaseId) {
this.actionName = "CREATE";
this.entityName = "INVESTMENTTRANSFER";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/transfer";
return this;
}
public CommandWrapperBuilder postInvestmentInterest(final Long purchaseId) {
this.actionName = "CREATE";
this.entityName = "INVESTMENTINTEREST";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/interest-postings";
return this;
}
public CommandWrapperBuilder createInvestmentDailyRate() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTDAILYRATE";
this.entityId = null;
this.href = "/investment/daily-rates";
return this;
}
public CommandWrapperBuilder deleteInvestmentDailyRate(final Long rateId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTDAILYRATE";
this.entityId = rateId;
this.href = "/investment/daily-rates/" + rateId;
return this;
}
public CommandWrapperBuilder createChequeStockLot() {
this.actionName = "CREATE";
this.entityName = "CHEQUESTOCKLOT";
@@ -42,8 +42,6 @@ public enum JobName {
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SEND_KYC_REMINDERS("Send KYC Reminders"), //
PROCESS_KYC_NOTICE_QUEUE("Process KYC Notice Queue"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
@@ -83,24 +83,15 @@ public class SavingsAccountSummaryData {
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/
private final Integer renewalCycleNo;
/**
* The branch this account is held at: the account's own office when it was opened at another branch than the
* client's, else the client's (or the group's) office. Lets a customer with accounts at several branches tell
* them apart on the client page.
*/
private final Long officeId;
private final String officeName;
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
final String fdAccountNumber, final Integer renewalCycleNo, final Long officeId, final String officeName) {
final String fdAccountNumber, final Integer renewalCycleNo) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
this.officeId = officeId;
this.officeName = officeName;
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -21,11 +21,7 @@ package org.apache.fineract.portfolio.aml.domain;
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
public enum AmlAppliesTo {
/**
* CURRENT is a current / Cash Credit account. The engine is not yet fed current-account transactions, so a rule written
* for it is catalogued but does not fire until it is.
*/
SAVINGS, LOAN, TRANSFER, CURRENT, ALL;
SAVINGS, LOAN, TRANSFER, ALL;
public boolean matches(AmlTransactionType transactionType) {
return this == ALL || this.name().equals(transactionType.name());
@@ -20,12 +20,14 @@ package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -38,7 +40,11 @@ import org.apache.fineract.useradministration.domain.AppUser;
*/
@Entity
@Table(name = "m_aml_recurring_review")
public class AmlRecurringReview extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public class AmlRecurringReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "client_id", nullable = false)
@@ -101,6 +107,10 @@ public class AmlRecurringReview extends AbstractAuditableWithUTCDateTimeCustom<L
this.alertsActioned = count;
}
public Long getId() {
return id;
}
public Client getClient() {
return client;
}
@@ -152,15 +152,6 @@ public class AmlTransactionRule {
return rule;
}
/**
* Narrows a rule to one direction - CREDIT (money in) or DEBIT (money out), null for either - and, when asked, to cash
* only. The engine already honours both; a rule written without them applies to every credit and debit, cash or not.
*/
public void restrictTo(final String direction, final boolean cashOnly) {
this.direction = direction;
this.cashOnly = cashOnly;
}
public Long getId() {
return id;
}
@@ -1,61 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import java.util.regex.Pattern;
/**
* The comparable fingerprint of an Aadhaar number: the SHA-256 of the 12 digits, as 64 lowercase hex characters.
*
* <p>
* The hash is computed by the client (browser) and only the hash is sent. The KYC create/update calls go through the
* command source, which records the request body in an audit table and in the log, so the 12-digit number must not be
* in the body. The server therefore never sees the number and only checks that what it was given has the shape of a
* SHA-256 digest.
*
* <p>
* A plain, unkeyed SHA-256 of a 12-digit number can be reversed by trying every possible number, so this column is a
* lookup key, not protection against someone who can read the table.
*/
public final class AadhaarHash {
private static final Pattern SHA256_HEX = Pattern.compile("^[0-9a-f]{64}$");
private AadhaarHash() {}
/** True when the value has the shape of a lowercase hex SHA-256 digest. */
public static boolean isValid(final String value) {
return value != null && SHA256_HEX.matcher(value).matches();
}
/**
* The value trimmed and lower-cased when it is a well-formed digest; null for blank input. A non-blank value that
* is not a digest is rejected rather than silently dropped, so a client bug cannot disable the duplicate check.
*/
public static String normalizeOrNull(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String candidate = raw.trim().toLowerCase();
if (!isValid(candidate)) {
throw new IllegalArgumentException("aadhaarHash must be a 64-character hex SHA-256 digest");
}
return candidate;
}
}
@@ -151,13 +151,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "aadhaar_number_masked", length = 12)
private String aadhaarNumberMasked;
/**
* SHA-256 of the 12-digit Aadhaar, computed client-side (see {@link AadhaarHash}). The masked form cannot tell two
* people apart; this can. Null for rows that predate the column and for clients with no Aadhaar.
*/
@Column(name = "aadhaar_hash", length = 64)
private String aadhaarHash;
@Column(name = "aadhaar_verified", nullable = false)
private boolean aadhaarVerified;
@@ -398,7 +391,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("aadhaarNumberMasked"),
command.stringValueOfParameterNamed("gstinNumber"),
command.stringValueOfParameterNamed("remarks"));
kyc.aadhaarHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed("aadhaarHash"));
kyc.applyExtendedFields(command);
return kyc;
}
@@ -501,21 +493,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
actualChanges.put(aadhaarParam, newValue);
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
// The Aadhaar was removed: the fingerprint of the old number must not outlive it.
if (this.aadhaarNumberMasked == null) {
this.aadhaarHash = null;
}
}
// Only the fingerprint is sent, never the number. Reported as a flag, not a value, because the changes map
// is echoed back in the API response.
final String aadhaarHashParam = "aadhaarHash";
if (command.parameterExists(aadhaarHashParam)) {
final String newHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed(aadhaarHashParam));
if (newHash != null && !newHash.equals(this.aadhaarHash)) {
this.aadhaarHash = newHash;
actualChanges.put("aadhaarIdentityUpdated", true);
}
}
final String gstinParam = "gstinNumber";
@@ -1073,10 +1050,6 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return aadhaarNumberMasked;
}
public String getAadhaarHash() {
return aadhaarHash;
}
public boolean isAadhaarVerified() {
return aadhaarVerified;
}
@@ -27,13 +27,6 @@ public interface ClientKycContactLogRepository extends JpaRepository<ClientKycCo
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
/** True when a notice carrying this marker has already reached a final outcome on the channel. */
boolean existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(Long clientId, KycContactChannel channel,
String markerPrefix, java.util.Collection<KycContactDeliveryStatus> statuses);
/** How many times a marked notice has been tried on the channel — bounds the retries of a failing gateway. */
long countByClientIdAndChannelAndNotesStartingWith(Long clientId, KycContactChannel channel, String markerPrefix);
/**
* Every contact log for the given clients in one round trip, so the exceptions grid can render
* "last contacted" for the whole page without an N+1 query per row.
@@ -61,18 +61,6 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
/**
* KYC records whose re-KYC falls due after {@code asOn} but no later than {@code until}, soonest first.
*
* <p>The complement of {@link #findLapsedAsOn}: a record is in one list or the other, never both, so the
* two together cover everything that needs the branch's attention. Rejected records are excluded for
* the same reason as there.
*/
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate > :asOn "
+ "AND k.expiryDate <= :until AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findExpiringBetween(@Param("asOn") LocalDate asOn, @Param("until") LocalDate until,
@Param("rejectedStatus") Integer rejectedStatus);
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
@@ -27,7 +27,6 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
@Entity
@@ -111,57 +110,11 @@ public class ClientRiskRule {
@Column(name = "rule_kind", length = 10)
private String ruleKind;
/**
* Whether this scenario applies to Cash Credit accounts - a current account that is allowed to draw an overdraft
* against a sanctioned limit. Lets the Rules Master show, in one place, every scenario that watches a CC account,
* whatever its coverage status.
*/
@Column(name = "cc_relevant", nullable = false)
private boolean ccRelevant;
/**
* A rule authored from the Rules Master starts on trial: it is listed first and marked new until an officer marks it
* normal. Every rule that was already in the catalog is normal.
*/
@Column(name = "on_trial", nullable = false)
private boolean onTrial;
@Column(name = "trial_started_on")
private LocalDate trialStartedOn;
public static final String KIND_KYC = "KYC";
public static final String KIND_AML = "AML";
protected ClientRiskRule() {}
public boolean isCcRelevant() {
return ccRelevant;
}
public boolean isOnTrial() {
return onTrial;
}
public LocalDate getTrialStartedOn() {
return trialStartedOn;
}
/** Puts a newly authored rule on trial from {@code startedOn}. */
public void startTrial(final LocalDate startedOn) {
this.onTrial = true;
this.trialStartedOn = startedOn;
}
/** Ends the trial: the rule is a normal part of the catalog. */
public void markNormal() {
this.onTrial = false;
}
/** Marks the scenario as one that watches Cash Credit accounts, so the Rules Master CC chip lists it. */
public void markCcRelevant(final boolean ccRelevant) {
this.ccRelevant = ccRelevant;
}
/**
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
* are attached separately by the caller, which owns the CodeValue lookup.
@@ -26,8 +26,7 @@ public interface SearchingClientRepository {
/**
* @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only.
* Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY).
* @param officeId null = no filter; otherwise only clients of that office, still inside officeHierarchy.
*/
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
Integer legalFormValue, Long officeId);
Integer legalFormValue);
}
@@ -53,7 +53,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
@Override
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
Integer legalFormValue, Long officeId) {
Integer legalFormValue) {
/*
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
* this point the upgrade is too costly
@@ -62,9 +62,6 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
*/
String hierarchyLikeValue = officeHierarchy + "%";
// Customers of other branches that hold an account at the caller's branch are listed here too.
final List<Long> linkedClientIds = clientIdsWithAccountAtBranch(hierarchyLikeValue);
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<SearchedClient> query = cb.createQuery(SearchedClient.class);
@@ -77,8 +74,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
Join<Client, ClientIdentifier> identity = r.join("identifiers", JoinType.LEFT);
List<Predicate> predicates = new ArrayList<>();
final Predicate inHierarchy = cb.like(o.get("hierarchy"), hierarchyLikeValue);
predicates.add(linkedClientIds.isEmpty() ? inHierarchy : cb.or(inHierarchy, r.get("id").in(linkedClientIds)));
predicates.add(cb.like(o.get("hierarchy"), hierarchyLikeValue));
String searchLikeValue = "%" + searchText + "%";
predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue),
@@ -97,11 +93,6 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
predicates.add(cb.equal(r.get("legalForm"), legalFormValue));
}
// Branch filter. ANDed with the hierarchy predicate above, so it can only narrow results.
if (officeId != null) {
predicates.add(cb.equal(o.get("id"), officeId));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
@@ -117,27 +108,4 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec);
}
/**
* Ids of clients holding an account (savings, fixed or recurring deposit, or current) at an office at or below the
* given hierarchy pattern, whatever office the client itself belongs to. Only accounts opened at a branch other than
* the client's carry their own office, so this is empty for a branch with no such customers, and for Head Office.
* Being linked to a branch is deliberately not enough to be listed there.
*/
@SuppressWarnings("unchecked")
private List<Long> clientIdsWithAccountAtBranch(final String hierarchyLikeValue) {
final List<Object> rows = entityManager.createNativeQuery(
"SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id "
+ "WHERE sa.client_id IS NOT NULL AND ao.hierarchy LIKE ?1 "
+ "UNION SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id "
+ "WHERE ca.client_id IS NOT NULL AND co.hierarchy LIKE ?2")
// Positional, not named: EclipseLink passes a native query's ":h" to PostgreSQL as written, which fails
// with a syntax error and took the whole client list down with it.
.setParameter(1, hierarchyLikeValue).setParameter(2, hierarchyLikeValue).getResultList();
final List<Long> ids = new ArrayList<>(rows.size());
for (final Object row : rows) {
ids.add(((Number) row).longValue());
}
return ids;
}
}
@@ -32,7 +32,6 @@ public enum NoteType {
GROUP(600, "noteType.group", "groups", "Group note"), //
SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), //
SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction note"), //
CURRENT_ACCOUNT(900, "noteType.currentaccount", "currentaccounts", "Current account note"), //
;
public static final NoteType[] VALUES = values();
@@ -28,22 +28,10 @@ public interface CurrentAccountReadPlatformService {
Map<String, Object> retrieveOneTransaction(Long accountId, Long transactionId);
/** The account's charges from m_current_account_charge; chargeStatus is all, active or inactive. */
List<Map<String, Object>> retrieveCharges(Long accountId, String chargeStatus);
Map<String, Object> retrieveCharge(Long accountId, Long accountChargeId);
/** Template for adding a charge: the active current-account charges in the account's currency. */
Map<String, Object> retrieveChargeTemplate(Long accountId);
List<Map<String, Object>> retrieveAll();
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName);
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate);
/** As above; with {@code overdraftOnly}, only the Cash Credit accounts - those that allow an overdraft. */
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate, boolean overdraftOnly);
}
@@ -1,197 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.interbranch.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Tells a person looking at one ledger transaction whether it is one branch's part of a transfer between branches.
*
* <p>
* A transfer between accounts at different branches is posted as one ledger transaction per branch, joined through the
* Liability Transfer account. Looking at either part on its own shows only two ledger lines, with nothing to say it has
* another part in another branch. The bank transfer log records the ledger ids of every part, so given any one of them
* this reports the whole transfer: who it moved money from and to, in which branches, and who raised and approved it.
*/
@Path("/v1/accounting/interbranchtransfers")
@Component
@RequiredArgsConstructor
@Produces(MediaType.APPLICATION_JSON)
public class InterBranchTransferApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Path("{transactionId}")
public String retrieve(@PathParam("transactionId") final String transactionId) {
this.context.authenticatedUser().validateHasReadPermission("JOURNALENTRY");
// Ledger transaction ids are short alphanumeric strings; anything else cannot be one.
if (transactionId == null || !transactionId.matches("[A-Za-z0-9_-]{1,50}")) {
return notInterBranch();
}
final List<Map<String, Object>> logs = this.jdbcTemplate.queryForList(
"SELECT voucher_no, total_amount, gl_txn_id, debit_entries, credit_entries, status FROM m_bank_transfer_log "
+ "WHERE (',' || gl_txn_id || ',') LIKE ? ORDER BY id DESC LIMIT 1",
"%," + transactionId + ",%");
if (logs.isEmpty()) {
return notInterBranch();
}
final Map<String, Object> log = logs.get(0);
final List<Map<String, Object>> from = legsOf(log.get("debit_entries"));
final List<Map<String, Object>> to = legsOf(log.get("credit_entries"));
final Set<Object> branchIds = new LinkedHashSet<>();
for (final Map<String, Object> leg : from) {
branchIds.add(leg.get("branchId"));
}
for (final Map<String, Object> leg : to) {
branchIds.add(leg.get("branchId"));
}
branchIds.remove(null);
if (branchIds.size() < 2) {
return notInterBranch();
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("interBranch", true);
out.put("voucherNo", log.get("voucher_no"));
out.put("amount", log.get("total_amount"));
out.put("status", log.get("status"));
out.put("thisTransactionId", transactionId);
out.put("from", from);
out.put("to", to);
out.put("parts", partsOf(String.valueOf(log.get("gl_txn_id"))));
// Raised and approved by: the clerk's request, when the transfer went through approval.
final List<Map<String, Object>> requests = this.jdbcTemplate.queryForList(
"SELECT clerk_name, manager_name, submitted_at, actioned_at FROM m_clerk_disbursement_request WHERE voucher_no = ? "
+ "ORDER BY id DESC LIMIT 1",
log.get("voucher_no"));
if (!requests.isEmpty()) {
final Map<String, Object> request = requests.get(0);
out.put("raisedBy", request.get("clerk_name"));
out.put("raisedAt", isoOf(request.get("submitted_at")));
out.put("approvedBy", request.get("manager_name"));
out.put("approvedAt", isoOf(request.get("actioned_at")));
}
return this.gson.toJson(out);
}
private String notInterBranch() {
return "{\"interBranch\":false}";
}
/** Each account on one side of the transfer, with the branch its books are in. */
private List<Map<String, Object>> legsOf(final Object storedJson) {
final List<Map<String, Object>> legs = new ArrayList<>();
if (storedJson == null) {
return legs;
}
final JsonArray entries = this.gson.fromJson(String.valueOf(storedJson), JsonArray.class);
for (final JsonElement element : entries) {
final JsonObject entry = element.getAsJsonObject();
final Map<String, Object> leg = new LinkedHashMap<>();
leg.put("accountNo", text(entry, "accountNo"));
leg.put("accountName", text(entry, "accountName"));
leg.put("amount", text(entry, "amount"));
final Long accountId = entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
? entry.get("savingsAccountId").getAsLong()
: null;
final Map<String, Object> branch = accountId == null ? null : branchOfAccount(text(entry, "accountType"), accountId);
leg.put("branchId", branch == null ? null : ((Number) branch.get("id")).longValue());
leg.put("branchName", branch == null ? null : branch.get("name"));
legs.add(leg);
}
return legs;
}
/** One entry per ledger transaction of the transfer, with the branch it was posted in. */
private List<Map<String, Object>> partsOf(final String glTxnIds) {
final List<Map<String, Object>> parts = new ArrayList<>();
for (final String id : glTxnIds.split(",")) {
final String ledgerId = id.trim();
if (ledgerId.isEmpty()) {
continue;
}
final List<Map<String, Object>> offices = this.jdbcTemplate.queryForList(
"SELECT DISTINCT o.id, o.name FROM acc_gl_journal_entry je JOIN m_office o ON o.id = je.office_id "
+ "WHERE je.transaction_id = ?",
ledgerId);
final Map<String, Object> part = new LinkedHashMap<>();
part.put("transactionId", ledgerId);
part.put("branchId", offices.isEmpty() ? null : ((Number) offices.get(0).get("id")).longValue());
part.put("branchName", offices.isEmpty() ? null : offices.get(0).get("name"));
parts.add(part);
}
return parts;
}
/**
* The branch an account's books are in: its own office when it was opened at another branch than the client's,
* otherwise the client's (or group's). A current account is its own table with its own ids.
*/
private Map<String, Object> branchOfAccount(final String accountType, final Long accountId) {
final String sql = "CURRENT".equalsIgnoreCase(accountType)
? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id "
+ "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?"
: "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id "
+ "LEFT JOIN m_group g ON g.id = sa.group_id "
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null);
}
private static String text(final JsonObject object, final String name) {
final JsonElement element = object.get(name);
return element == null || element.isJsonNull() ? null : element.getAsString();
}
private static String isoOf(final Object value) {
if (value == null) {
return null;
}
if (value instanceof java.sql.Timestamp) {
return ((java.sql.Timestamp) value).toLocalDateTime().toString();
}
return String.valueOf(value).replace(' ', 'T');
}
}
@@ -1,162 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.interbranch.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Keeps a branch's cash ledger true when a cashier takes or pays cash for an account held at another branch.
*
* <p>
* A savings deposit or withdrawal always posts its ledger entries in the branch the account is held at, because that is
* where the account's books are. The cash itself, though, is in the drawer of whichever cashier handled it. When that
* cashier sits at a different branch, the account's branch would show cash it never received (or paid), and the
* cashier's branch would hold cash its ledger does not know about.
*
* <p>
* This moves the cash leg to the cashier's branch, through the Liability Transfer account, exactly as a transfer
* between two branches' accounts is booked: the account's branch reverses the cash line against Liability Transfer, and
* the cashier's branch books the same cash line against it. Each branch's books balance on their own and the bank's
* total does not change. A cashier at the account's own branch, or a user who is not a cashier, changes nothing.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class InterBranchCashService {
private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200;
private static final int CREDIT = 1;
private static final int DEBIT = 2;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
/**
* @param savingsTransactionId
* the deposit or withdrawal just posted
* @param accountOfficeId
* the branch the account is held at, where that posting was booked
* @param deposit
* true for cash received, false for cash paid out
*/
public void moveCashLegToCashierBranch(final Long savingsTransactionId, final Long accountOfficeId, final String accountNo,
final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) {
final AppUser user = this.context.authenticatedUser();
if (user.getOffice() == null || accountOfficeId == null || savingsTransactionId == null) {
return;
}
if (!isCashier(user.getId())) {
return;
}
moveCashLegToBranch(savingsTransactionId, accountOfficeId, user.getOffice().getId(), accountNo, amount, transactionDate, deposit);
}
/**
* Same as {@link #moveCashLegToCashierBranch}, for a posting made by someone other than the cashier who handled the
* cash, such as a manager approving a deposit a cashier took earlier. The branch the cash is in is given.
*/
public void moveCashLegToBranch(final Long savingsTransactionId, final Long accountOfficeId, final Long cashierOfficeId,
final String accountNo, final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) {
final AppUser user = this.context.authenticatedUser();
if (cashierOfficeId == null || accountOfficeId == null || savingsTransactionId == null
|| cashierOfficeId.equals(accountOfficeId)) {
return;
}
// The cash line of the posting just made: the debit of a deposit, the credit of a withdrawal.
final List<Map<String, Object>> cashLines = this.jdbcTemplate.queryForList(
"SELECT account_id, currency_code FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ? "
+ "AND amount = ? AND reversed = false ORDER BY id DESC",
savingsTransactionId, deposit ? DEBIT : CREDIT, amount);
if (cashLines.isEmpty()) {
log.warn("No cash line found for savings transaction {}; its cash was not moved to the cashier's branch",
savingsTransactionId);
return;
}
final Long cashGlId = ((Number) cashLines.get(0).get("account_id")).longValue();
final String currencyCode = String.valueOf(cashLines.get(0).get("currency_code"));
final Long suspenseGlId = liabilityTransferGlId();
final String note = (deposit ? "Inter-branch cash deposit" : "Inter-branch cash withdrawal") + " | A/C " + accountNo
+ " | savings txn " + savingsTransactionId + " | cashier " + user.getUsername() + " | via Liability Transfer";
// Account's branch: take the cash line back out. A deposit's was Dr cash, so Cr cash; a withdrawal's was Cr cash.
post(accountOfficeId, cashGlId, deposit ? CREDIT : DEBIT, suspenseGlId, deposit ? DEBIT : CREDIT, amount, currencyCode,
transactionDate, user.getId(), note);
// Cashier's branch: the cash really moved here.
post(cashierOfficeId, cashGlId, deposit ? DEBIT : CREDIT, suspenseGlId, deposit ? CREDIT : DEBIT, amount, currencyCode,
transactionDate, user.getId(), note);
}
private boolean isCashier(final Long userId) {
final Integer count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?", Integer.class, userId);
return count != null && count > 0;
}
private Long liabilityTransferGlId() {
final List<Long> ids = this.jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_LIABILITY_TRANSFER);
if (ids.isEmpty()) {
throw new IllegalStateException("A cash deposit or withdrawal for another branch's account needs the Liability Transfer "
+ "account mapped under Accounting > Financial Activity Mappings.");
}
return ids.get(0);
}
/** One balanced entry in one branch: the cash line and its Liability Transfer counterpart. */
private void post(final Long officeId, final Long cashGlId, final int cashType, final Long suspenseGlId, final int suspenseType,
final BigDecimal amount, final String currencyCode, final LocalDate entryDate, final long userId, final String note) {
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.INTER_BRANCH_CASH);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
insert(officeId, cashGlId, cashType, amount, txnId, entryDate, userId, now, currencyCode, note);
insert(officeId, suspenseGlId, suspenseType, amount, txnId, entryDate, userId, now, currencyCode, note);
}
private void insert(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final long userId, final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
final java.time.OffsetDateTime utc = now.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
this.jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry (account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, organization_running_balance, submitted_on_date, "
+ " transaction_date, created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), utc, utc);
}
}
@@ -74,16 +74,12 @@ public final class LedgerTxnPrefix {
public static final String CASH_MIGRATION = "CSHMIG";
/** Rounding difference on a clerk's day closure. */
public static final String CLOSURE_ROUNDING = "CLSRND";
/** Cash line of a deposit or withdrawal moved to the cashier's branch, when the account is held at another. */
public static final String INTER_BRANCH_CASH = "IBCASH";
// ── Year end ──────────────────────────────────────────────────────────────
/** Year-end transfer of income into the profit account. */
public static final String YEAR_END_PROFIT = "YEPFT";
/** Year-end closure of an income or expense account. */
public static final String YEAR_END_CLOSE = "YECLS";
/** Appropriation of a closed year's profit into the reserve funds. */
public static final String PROFIT_APPROPRIATION = "PRAPP";
// ── Savings ───────────────────────────────────────────────────────────────
/** Pooled savings interest posting for one product and date. */
@@ -366,7 +366,7 @@ public class IncomeProfitTransferService {
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
}
public String resolveCurrency(final Long glAccountId) {
private String resolveCurrency(final Long glAccountId) {
try {
final String code = jdbcTemplate.queryForObject(
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
@@ -377,8 +377,7 @@ public class IncomeProfitTransferService {
}
}
/** Writes one journal entry leg. Shared with the profit appropriation posting so both year-end steps post alike. */
public void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update(
@@ -238,11 +238,6 @@ public class SecurityConfig {
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
// current accounts are deposit accounts: their notes use the savings-note permissions
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/groups/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE")
// notes: create
@@ -254,8 +249,6 @@ public class SecurityConfig {
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currentaccounts/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/groups/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE")
// notes: update
@@ -267,8 +260,6 @@ public class SecurityConfig {
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currentaccounts/*/notes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/groups/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE")
// notes: delete
@@ -280,8 +271,6 @@ public class SecurityConfig {
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/currentaccounts/*/notes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/groups/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE")
// document: clients
@@ -92,7 +92,7 @@ public class TenantMenuAccessApiResource {
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT", "MENU_CREDIT_BUREAU");
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
/**
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
@@ -361,15 +361,9 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
// this row's cycle within the chain, so a client's account list can collapse a renewed
// FD into one entry rather than listing every term as a separate account.
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo, ");
// The branch the account is held at: its own office when opened at another branch, else the client's or group's.
accountsSummary.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName ");
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
accountsSummary.append("from m_savings_account sa ");
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
accountsSummary.append("left join m_client acct_client on acct_client.id = sa.client_id ");
accountsSummary.append("left join m_group acct_group on acct_group.id = sa.group_id ");
accountsSummary.append(
"left join m_office acct_office on acct_office.id = coalesce(sa.office_id, acct_client.office_id, acct_group.office_id) ");
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
@@ -470,12 +464,9 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final String fdAccountNumber = rs.getString("fdAccountNumber");
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId");
final String accountOfficeName = rs.getString("accountOfficeName");
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo, accountOfficeId, accountOfficeName);
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
}
}
@@ -505,13 +496,9 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
sb.append("ca.currency_code as currencyCode, ca.currency_digits as currencyDigits, ca.currency_multiplesof as inMultiplesOf, ");
sb.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
sb.append("curr.display_symbol as currencyDisplaySymbol, ");
sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName, ");
// The branch the account is held at: its own office, else the client's.
sb.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName ");
sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName ");
sb.append("from m_current_account ca ");
sb.append("join m_current_product cp on cp.id = ca.product_id ");
sb.append("left join m_client acct_client on acct_client.id = ca.client_id ");
sb.append("left join m_office acct_office on acct_office.id = coalesce(ca.office_id, acct_client.office_id) ");
sb.append("join m_currency curr on curr.code = ca.currency_code ");
sb.append("left join m_appuser sbu on sbu.id = ca.submittedon_userid ");
sb.append("left join m_appuser rbu on rbu.id = ca.rejectedon_userid ");
@@ -591,13 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId");
final String accountOfficeName = rs.getString("accountOfficeName");
// Current accounts have no deposit term, so no FD renewal chain to report.
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
null, null, accountOfficeId, accountOfficeName);
null, null);
}
}
@@ -1,672 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.almreturn.api;
import com.google.gson.Gson;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.sql.Date;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The fixed-term part of the Structural Liquidity Statement: what the bank's customers' Fixed
* Deposits, Recurring Deposits and term loans add up to in each residual-maturity bucket, counted
* from an as-on date.
*
* <h3>How each figure is slotted</h3>
*
* <ul>
* <li><b>Fixed and Recurring Deposits</b> (outflow): the account balance, on the deposit's maturity
* date. RBI slots term deposits by residual maturity; accrued interest is a separate line of the
* statement ("Interest Payable"), so it is not added here. A deposit already at or past its
* maturity is an overdue liability and goes in the first bucket.</li>
* <li><b>Term loans</b> (inflow): each unpaid instalment's principal, on that instalment's due date -
* RBI asks for the interim cash flows, not the whole loan in one bucket. An instalment already due
* is overdue: one that fell due less than a month ago goes in the "recent" bucket and an older one
* in the "old" bucket. Both bucket positions are parameters, because they are the bank's
* Overdue Receivables rule and not something this code should decide.</li>
* <li><b>Not slotted by date</b>, reported on their own so they are not silently lost: NPA loans (RBI
* slots them by classification, not by instalment) and cash-credit / overdraft / demand loans (their
* stored schedule is a synthetic equal-instalment one, not a real repayment pattern).</li>
* </ul>
*
* <h3>What this does not cover</h3>
*
* The bank's own placements and borrowings - FDs with other banks, investments, borrowings - have no
* register in the system, only ledger balances, so there is nothing to slot by date. The response
* says so rather than leaving them out silently.
*
* <h3>As-on date</h3>
*
* Accounts are read in their state today. The as-on date only decides where a maturity falls
* relative to it; it does not rebuild what the books looked like on an earlier day.
*
* <h3>Buckets</h3>
*
* The bucket edges arrive from the caller, since the bank edits them. {@code buckets=14d,28d,3m,6m,1y,3y,5y}
* means the first bucket ends 14 days out, the second 28 days out, the third 3 calendar months out
* and so on, with a last bucket that has no end. Months and years are calendar ones.
*/
@Path("/v1/almreturn/maturity-breakdown")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class AlmMaturityBreakdownApiResource {
/** Same audience as the other RBI returns, so no separate permission is introduced for it. */
private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN";
/** The bucket edges used when the caller names none: RBI's non-scheduled UCB structure. */
private static final String DEFAULT_BUCKETS = "14d,28d,3m,6m,1y,3y,5y";
private static final int FIXED_DEPOSIT = 200;
private static final int RECURRING_DEPOSIT = 300;
/** SavingsAccountStatusType ACTIVE and MATURED: a matured deposit is still a liability until closed or renewed. */
private static final int SAVINGS_ACTIVE = 300;
private static final int SAVINGS_MATURED = 800;
/** LoanStatus.ACTIVE. */
private static final int LOAN_ACTIVE = 300;
private static final Pattern EDGE = Pattern.compile("^(\\d{1,4})([dmy])$");
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String retrieve(@QueryParam("asOnDate") final String asOnDateParam, @QueryParam("officeId") final Long officeId,
@QueryParam("buckets") final String bucketsParam, @QueryParam("overdueRecentBucket") final Integer overdueRecentBucket,
@QueryParam("overdueOldBucket") final Integer overdueOldBucket) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate asOn = parseDate(asOnDateParam);
final List<LocalDate> limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam);
final int bucketCount = limits.size() + 1;
final int recent = clampBucket(overdueRecentBucket, 0, bucketCount);
final int old = clampBucket(overdueOldBucket, 2, bucketCount);
final String hierarchy = scopeHierarchy(officeId) + "%";
final List<Map<String, Object>> groups = new ArrayList<>();
groups.add(depositGroup("FD", "Fixed Deposits", FIXED_DEPOSIT, asOn, limits, hierarchy));
groups.add(depositGroup("RD", "Recurring Deposits", RECURRING_DEPOSIT, asOn, limits, hierarchy));
groups.add(termLoanGroup(asOn, limits, hierarchy, recent, old));
final Map<String, Object> response = new LinkedHashMap<>();
response.put("asOnDate", asOn.toString());
response.put("officeId", officeId);
response.put("bucketCount", bucketCount);
response.put("bucketEnds", limits.stream().map(LocalDate::toString).toList());
response.put("groups", groups);
response.put("notSlotted", List.of(npaLoans(hierarchy), revolvingLoans(hierarchy)));
response.put("notes", List.of(
"Deposits are slotted by their balance on the maturity date; accrued interest belongs to the Interest Payable line.",
"Term loans are slotted instalment by instalment (principal only); accrued interest belongs to the Interest Receivable line.",
"Accounts are read in their state today; the as-on date only decides where a maturity falls.",
"The bank's own FDs with other banks, investments and borrowings have no register, so they are not part of this breakdown."));
return this.gson.toJson(response);
}
// ─────────────────────────── Deposits ───────────────────────────
private Map<String, Object> depositGroup(final String key, final String label, final int depositType, final LocalDate asOn,
final List<LocalDate> limits, final String hierarchy) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT datp.maturity_date AS "d",
sa.status_enum AS "st",
COUNT(*) AS "n",
COALESCE(SUM(sa.account_balance_derived), 0) AS "amt"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
WHERE sa.deposit_type_enum = ?
AND sa.status_enum IN (?, ?)
AND o.hierarchy LIKE ?
GROUP BY datp.maturity_date, sa.status_enum
""", depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy);
final BigDecimal[] buckets = zeros(limits.size() + 1);
long accounts = 0;
for (final Map<String, Object> row : rows) {
final long n = ((Number) row.get("n")).longValue();
final BigDecimal amount = (BigDecimal) row.get("amt");
accounts += n;
final Date maturity = (Date) row.get("d");
// A MATURED deposit is already past its date and still owed: the first bucket, whatever the date says.
final boolean matured = ((Number) row.get("st")).intValue() == SAVINGS_MATURED;
final int index = matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits);
buckets[index] = buckets[index].add(amount);
}
return group(key, label, "liability", accounts, buckets);
}
// ─────────────────────────── Loans ───────────────────────────
private Map<String, Object> termLoanGroup(final LocalDate asOn, final List<LocalDate> limits, final String hierarchy,
final int recentOverdueBucket, final int oldOverdueBucket) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT rs.duedate AS "d",
COALESCE(SUM(GREATEST(rs.principal_amount
- COALESCE(rs.principal_completed_derived, 0)
- COALESCE(rs.principal_writtenoff_derived, 0), 0)), 0) AS "amt"
FROM m_loan_repayment_schedule rs
JOIN m_loan l ON l.id = rs.loan_id
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND COALESCE(l.is_npa, false) = false
AND COALESCE(rs.completed_derived, false) = false
AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
WHERE ext.loan_product_id = l.product_id
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
AND o.hierarchy LIKE ?
GROUP BY rs.duedate
""", LOAN_ACTIVE, hierarchy);
final BigDecimal[] buckets = zeros(limits.size() + 1);
final LocalDate recentCutoff = asOn.minusMonths(1);
for (final Map<String, Object> row : rows) {
final LocalDate due = ((Date) row.get("d")).toLocalDate();
final BigDecimal amount = (BigDecimal) row.get("amt");
final int index;
if (due.isAfter(asOn)) {
index = bucketIndex(asOn, due, limits);
} else {
// Already due and unpaid. Under a month overdue is still expected shortly; older is not.
index = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket;
}
buckets[index] = buckets[index].add(amount);
}
final Long loans = this.jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM m_loan l
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND COALESCE(l.is_npa, false) = false
AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
WHERE ext.loan_product_id = l.product_id
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
AND o.hierarchy LIKE ?
""", Long.class, LOAN_ACTIVE, hierarchy);
return group("LOAN", "Term Loans (principal instalments)", "asset", loans == null ? 0 : loans, buckets);
}
/** NPA loans: RBI slots them by classification (sub-standard / doubtful / loss), not by instalment date. */
private Map<String, Object> npaLoans(final String hierarchy) {
return outstanding("NPA", "NPA loans", """
SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt"
FROM m_loan l
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ? AND COALESCE(l.is_npa, false) = true AND o.hierarchy LIKE ?
""", "Slotted by classification (sub-standard, doubtful, loss), net of provision, not by instalment date.",
hierarchy);
}
/** CC / OD / demand loans: the stored schedule is synthetic, so the volatile/core split rule applies instead. */
private Map<String, Object> revolvingLoans(final String hierarchy) {
return outstanding("REVOLVING", "CC / OD / demand loans", """
SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt"
FROM m_loan l
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND COALESCE(l.is_npa, false) = false
AND EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
WHERE ext.loan_product_id = l.product_id
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
AND o.hierarchy LIKE ?
""", "Split into a volatile and a core share by the bank's own rule; the stored instalment schedule is not a real repayment pattern.",
hierarchy);
}
/** One count-and-total line for loans that are reported apart from the date buckets. */
private Map<String, Object> outstanding(final String key, final String label, final String sql, final String why,
final String hierarchy) {
final Map<String, Object> row = this.jdbcTemplate.queryForMap(sql, LOAN_ACTIVE, hierarchy);
final Map<String, Object> line = new LinkedHashMap<>();
line.put("key", key);
line.put("label", label);
line.put("side", "asset");
line.put("accounts", ((Number) row.get("n")).longValue());
line.put("outstanding", row.get("amt"));
line.put("why", why);
return line;
}
// ─────────────────────────── Picking accounts one by one ───────────────────────────
/** How many matches one search returns. */
private static final int SEARCH_LIMIT = 25;
/** How many accounts one report may cover. */
private static final int SELECTED_LIMIT = 200;
/** A loan product whose stored schedule is not a real repayment pattern: cash credit, overdraft, demand. */
private static final String REVOLVING_PRODUCT = "EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext"
+ " WHERE ext.loan_product_id = l.product_id"
+ " AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc') OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))";
/**
* Find fixed deposits, recurring deposits and loans by account number or customer name, so a few of
* them can be picked and broken down by days.
*
* @param kind
* FD, RD or LOAN to look in one kind only; empty to look in all three
*/
@GET
@Path("search")
public String search(@QueryParam("q") final String q, @QueryParam("kind") final String kind,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final String text = q == null ? "" : q.trim().toLowerCase();
final List<Map<String, Object>> results = new ArrayList<>();
if (text.length() >= 2) {
final String like = "%" + text + "%";
final String hierarchy = scopeHierarchy(officeId) + "%";
final String wanted = kind == null ? "" : kind.trim().toUpperCase();
if (wanted.isEmpty() || "FD".equals(wanted)) {
results.addAll(searchDeposits(FIXED_DEPOSIT, "FD", like, hierarchy));
}
if (wanted.isEmpty() || "RD".equals(wanted)) {
results.addAll(searchDeposits(RECURRING_DEPOSIT, "RD", like, hierarchy));
}
if (wanted.isEmpty() || "LOAN".equals(wanted)) {
results.addAll(searchLoans(like, hierarchy));
}
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("results", results);
return this.gson.toJson(response);
}
/**
* The day-wise breakdown of the accounts that were picked: each deposit as one row on its maturity
* date, each loan as one row per unpaid instalment on its due date, every row with the days to that
* date from the as-on date and the bucket it falls in.
*
* @param accountsParam
* the picked accounts as KIND:id pairs, for example FD:12,RD:5,LOAN:33
*/
@GET
@Path("selected")
public String selected(@QueryParam("accounts") final String accountsParam, @QueryParam("asOnDate") final String asOnDateParam,
@QueryParam("officeId") final Long officeId, @QueryParam("buckets") final String bucketsParam,
@QueryParam("overdueRecentBucket") final Integer overdueRecentBucket,
@QueryParam("overdueOldBucket") final Integer overdueOldBucket) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
if (accountsParam == null || accountsParam.isBlank()) {
throw new BadRequestException("accounts is required, like FD:12,RD:5,LOAN:33");
}
final LocalDate asOn = parseDate(asOnDateParam);
final List<LocalDate> limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam);
final int bucketCount = limits.size() + 1;
final String hierarchy = scopeHierarchy(officeId) + "%";
final List<Long> fixed = new ArrayList<>();
final List<Long> recurring = new ArrayList<>();
final List<Long> loans = new ArrayList<>();
for (final String token : accountsParam.split(",")) {
final String[] parts = token.trim().split(":");
if (parts.length != 2) {
throw new BadRequestException("accounts must look like FD:12,RD:5,LOAN:33");
}
final long id;
try {
id = Long.parseLong(parts[1].trim());
} catch (final NumberFormatException e) {
throw new BadRequestException("Account id '" + parts[1] + "' is not a number");
}
switch (parts[0].trim().toUpperCase()) {
case "FD" -> fixed.add(id);
case "RD" -> recurring.add(id);
case "LOAN" -> loans.add(id);
default -> throw new BadRequestException("Account kind '" + parts[0] + "' must be FD, RD or LOAN");
}
}
if (fixed.size() + recurring.size() + loans.size() > SELECTED_LIMIT) {
throw new BadRequestException("Pick at most " + SELECTED_LIMIT + " accounts at a time");
}
final List<Map<String, Object>> rows = new ArrayList<>();
if (!fixed.isEmpty()) {
rows.addAll(depositRows(FIXED_DEPOSIT, "FD", fixed, asOn, limits, hierarchy));
}
if (!recurring.isEmpty()) {
rows.addAll(depositRows(RECURRING_DEPOSIT, "RD", recurring, asOn, limits, hierarchy));
}
if (!loans.isEmpty()) {
rows.addAll(instalmentRows(loans, asOn, limits, hierarchy, clampBucket(overdueRecentBucket, 0, bucketCount),
clampBucket(overdueOldBucket, 2, bucketCount)));
}
// By date, undated rows last, then by account.
rows.sort(Comparator.comparing((Map<String, Object> r) -> String.valueOf(r.get("date")))
.thenComparing(r -> String.valueOf(r.get("accountNo"))));
BigDecimal total = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
total = total.add((BigDecimal) row.get("amount"));
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("asOnDate", asOn.toString());
response.put("bucketCount", bucketCount);
response.put("count", rows.size());
response.put("total", total);
response.put("rows", rows);
return this.gson.toJson(response);
}
private List<Map<String, Object>> searchDeposits(final int depositType, final String kind, final String like,
final String hierarchy) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList("""
SELECT sa.id AS "id",
sa.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "customer",
sp.name AS "product",
datp.maturity_date AS "d",
COALESCE(sa.account_balance_derived, 0) AS "amt"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
WHERE sa.deposit_type_enum = ?
AND sa.status_enum IN (?, ?)
AND o.hierarchy LIKE ?
AND (LOWER(sa.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?)
ORDER BY sa.account_no
LIMIT ?
""", depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy, like, like, SEARCH_LIMIT);
return hits(found, kind);
}
private List<Map<String, Object>> searchLoans(final String like, final String hierarchy) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList("""
SELECT l.id AS "id",
l.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "customer",
lp.name AS "product",
COALESCE(l.principal_outstanding_derived, 0) AS "amt"
FROM m_loan l
JOIN m_product_loan lp ON lp.id = l.product_id
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND o.hierarchy LIKE ?
AND (LOWER(l.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?)
ORDER BY l.account_no
LIMIT ?
""", LOAN_ACTIVE, hierarchy, like, like, SEARCH_LIMIT);
return hits(found, "LOAN");
}
private static List<Map<String, Object>> hits(final List<Map<String, Object>> found, final String kind) {
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> r : found) {
final Date date = (Date) r.get("d");
final Map<String, Object> row = new LinkedHashMap<>();
row.put("kind", kind);
row.put("id", r.get("id"));
row.put("accountNo", r.get("accountNo"));
row.put("customer", r.get("customer"));
row.put("product", r.get("product"));
row.put("date", date == null ? null : date.toLocalDate().toString());
row.put("amount", r.get("amt"));
rows.add(row);
}
return rows;
}
private static String placeholders(final int count) {
return String.join(",", Collections.nCopies(count, "?"));
}
private List<Map<String, Object>> depositRows(final int depositType, final String label, final List<Long> ids,
final LocalDate asOn, final List<LocalDate> limits, final String hierarchy) {
final String sql = """
SELECT sa.id AS "id",
sa.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "customer",
sp.name AS "product",
datp.maturity_date AS "d",
sa.status_enum AS "st",
COALESCE(sa.account_balance_derived, 0) AS "amt"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
WHERE sa.deposit_type_enum = ?
AND sa.status_enum IN (?, ?)
AND o.hierarchy LIKE ?
AND sa.id IN (%s)
ORDER BY datp.maturity_date, sa.account_no
""".formatted(placeholders(ids.size()));
final List<Object> args = new ArrayList<>(List.of(depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy));
args.addAll(ids);
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final Date maturity = (Date) r.get("d");
final boolean matured = ((Number) r.get("st")).intValue() == SAVINGS_MATURED;
final Map<String, Object> row = new LinkedHashMap<>();
row.put("id", r.get("id"));
row.put("accountNo", r.get("accountNo"));
row.put("customer", r.get("customer"));
row.put("product", r.get("product"));
row.put("kind", label);
row.put("date", maturity == null ? null : maturity.toLocalDate().toString());
row.put("days", maturity == null ? null : ChronoUnit.DAYS.between(asOn, maturity.toLocalDate()));
row.put("status", matured ? "MATURED" : "ACTIVE");
// Same rule as the totals: a matured deposit is overdue and sits in the first bucket.
row.put("bucket", matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits));
row.put("amount", r.get("amt"));
rows.add(row);
}
return rows;
}
/**
* The unpaid instalments of the picked loans. A picked loan is shown by its schedule whatever it is,
* with a note where the schedule is not the whole story (an NPA, or a cash credit / overdraft).
*/
private List<Map<String, Object>> instalmentRows(final List<Long> ids, final LocalDate asOn, final List<LocalDate> limits,
final String hierarchy, final int recentOverdueBucket, final int oldOverdueBucket) {
final String sql = """
SELECT l.id AS "id",
l.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "customer",
lp.name AS "product",
rs.installment AS "installment",
rs.duedate AS "d",
GREATEST(rs.principal_amount
- COALESCE(rs.principal_completed_derived, 0)
- COALESCE(rs.principal_writtenoff_derived, 0), 0) AS "amt",
COALESCE(l.is_npa, false) AS "npa",
(%s) AS "revolving"
FROM m_loan_repayment_schedule rs
JOIN m_loan l ON l.id = rs.loan_id
JOIN m_product_loan lp ON lp.id = l.product_id
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND COALESCE(rs.completed_derived, false) = false
AND GREATEST(rs.principal_amount
- COALESCE(rs.principal_completed_derived, 0)
- COALESCE(rs.principal_writtenoff_derived, 0), 0) > 0
AND o.hierarchy LIKE ?
AND l.id IN (%s)
ORDER BY rs.duedate, l.account_no
""".formatted(REVOLVING_PRODUCT, placeholders(ids.size()));
final List<Object> args = new ArrayList<>(List.of(LOAN_ACTIVE, hierarchy));
args.addAll(ids);
final LocalDate recentCutoff = asOn.minusMonths(1);
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final LocalDate due = ((Date) r.get("d")).toLocalDate();
final int bucket;
if (due.isAfter(asOn)) {
bucket = bucketIndex(asOn, due, limits);
} else {
bucket = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket;
}
final boolean npa = Boolean.TRUE.equals(r.get("npa"));
final boolean revolving = Boolean.TRUE.equals(r.get("revolving"));
final Map<String, Object> row = new LinkedHashMap<>();
row.put("id", r.get("id"));
row.put("accountNo", r.get("accountNo"));
row.put("customer", r.get("customer"));
row.put("product", r.get("product"));
row.put("kind", "LOAN");
row.put("installment", r.get("installment"));
row.put("date", due.toString());
row.put("days", ChronoUnit.DAYS.between(asOn, due));
row.put("status", due.isAfter(asOn) ? "DUE LATER" : "OVERDUE");
row.put("bucket", bucket);
row.put("amount", r.get("amt"));
row.put("note", npa ? "NPA loan: RBI slots it by classification, not by instalment date"
: revolving ? "CC / OD loan: the stored schedule is not a real repayment pattern" : null);
rows.add(row);
}
return rows;
}
// ─────────────────────────── Helpers ───────────────────────────
private static Map<String, Object> group(final String key, final String label, final String side, final long accounts,
final BigDecimal[] buckets) {
BigDecimal total = BigDecimal.ZERO;
for (final BigDecimal b : buckets) {
total = total.add(b);
}
final Map<String, Object> group = new LinkedHashMap<>();
group.put("key", key);
group.put("label", label);
group.put("side", side);
group.put("accounts", accounts);
group.put("total", total);
group.put("buckets", List.of(buckets));
return group;
}
private static BigDecimal[] zeros(final int size) {
final BigDecimal[] values = new BigDecimal[size];
java.util.Arrays.fill(values, BigDecimal.ZERO);
return values;
}
/** Bucket of a maturity: due or overdue goes in the first; otherwise the first bucket whose end it does not pass. */
static int bucketIndex(final LocalDate asOn, final LocalDate maturity, final List<LocalDate> limits) {
if (!maturity.isAfter(asOn)) {
return 0;
}
for (int i = 0; i < limits.size(); i++) {
if (!maturity.isAfter(limits.get(i))) {
return i;
}
}
return limits.size();
}
/** "14d,28d,3m" to the dates those edges reach from the as-on date. Calendar months and years; edges must climb. */
private static List<LocalDate> parseLimits(final LocalDate asOn, final String spec) {
final List<LocalDate> limits = new ArrayList<>();
for (final String token : spec.split(",")) {
final Matcher m = EDGE.matcher(token.trim().toLowerCase());
if (!m.matches() || Integer.parseInt(m.group(1)) < 1) {
throw new BadRequestException("Bucket edge '" + token + "' is not like 14d, 3m or 1y");
}
final int n = Integer.parseInt(m.group(1));
final LocalDate limit = switch (m.group(2)) {
case "d" -> asOn.plusDays(n);
case "m" -> asOn.plusMonths(n);
default -> asOn.plusYears(n);
};
if (!limits.isEmpty() && !limit.isAfter(limits.get(limits.size() - 1))) {
throw new BadRequestException("Bucket edges must run in increasing order: " + spec);
}
limits.add(limit);
}
return limits;
}
private static int clampBucket(final Integer requested, final int fallback, final int bucketCount) {
final int value = requested == null ? fallback : requested;
return Math.max(0, Math.min(value, bucketCount - 1));
}
private static LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return DateUtils.getBusinessLocalDate();
}
try {
return LocalDate.parse(raw);
} catch (final java.time.format.DateTimeParseException e) {
throw new BadRequestException("asOnDate must be yyyy-MM-dd");
}
}
/** The office tree a caller may read: their own, or a branch under it if they asked for one. */
private String scopeHierarchy(final Long officeId) {
final String own = this.context.authenticatedUser().getOffice().getHierarchy();
if (officeId == null) {
return own;
}
final List<String> asked = this.jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class, officeId);
return !asked.isEmpty() && asked.get(0).startsWith(own) ? asked.get(0) : own;
}
}
@@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.aml.job;
import java.time.YearMonth;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.springframework.batch.core.StepContribution;
@@ -31,8 +30,9 @@ import org.springframework.batch.repeat.RepeatStatus;
/**
* Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical
* threshold aggregation only. Scheduled for 02:00 on the 1st (changelog 0606), and still available from the
* admin endpoint (see {@code AmlCtrReportApiResource}) for a month run by hand.
* threshold aggregation only. Admin-triggered today (see {@code AmlCtrReportApiResource}); wiring
* to a real monthly cron is a follow-up, matching the same "driven by an admin endpoint today"
* stance already used for {@code NeftBatchProcessorService}.
*/
@Slf4j
@RequiredArgsConstructor
@@ -42,9 +42,7 @@ public class AmlCtrReportBatchTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
// The business date, not the wall clock: the month that has just ended is the bank's month, and the two dates
// are not always the same day.
final YearMonth previousMonth = YearMonth.from(DateUtils.getBusinessLocalDate()).minusMonths(1);
final YearMonth previousMonth = YearMonth.now().minusMonths(1);
try {
final AmlCtrReport report = generationService.generateForMonth(previousMonth);
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
@@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.investment.service.InvestmentFdInterestService;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator;
@@ -95,8 +97,16 @@ public class BranchDayRegisterApiResource {
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final FixedDepositInterestPostingReportService fdInterestPostingReportService;
private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator;
private final InvestmentPurchaseReadPlatformService investmentPurchaseReadPlatformService;
private final InvestmentFdInterestService investmentFdInterestService;
private final Gson gson = new Gson();
private boolean isHeadOffice(final Long officeId) {
final Integer topLevel = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ? AND parent_id IS NULL",
Integer.class, officeId);
return topLevel != null && topLevel > 0;
}
// ── GET /v1/branchday ── the register itself ──────────────────────────────
/**
@@ -260,6 +270,13 @@ public class BranchDayRegisterApiResource {
final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate);
result.put("fdInterestPending", fdInterest.get("pending"));
result.put("fdInterestDue", fdInterest.get("due"));
// Investment interest vouchers (coupon / provision) whose date has come but which are not posted yet.
// A reminder only: it never stops the close (Jitendra Sir). Investments are booked at Head Office, so
// only the Head Office close shows it.
result.put("investmentInterestPending",
isHeadOffice(officeId) ? investmentPurchaseReadPlatformService.retrieveDueInterest(0) : List.of());
// The same for FDs placed with other banks: interest vouchers and maturities due. A reminder only.
result.put("investmentFdPending", isHeadOffice(officeId) ? investmentFdInterestService.due(0) : List.of());
return gson.toJson(result);
}
@@ -56,7 +56,6 @@ 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;
@@ -99,8 +98,6 @@ public class CashierDepositRequestApiResource {
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final org.apache.fineract.accounting.interbranch.service.InterBranchCashService interBranchCashService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
@@ -158,11 +155,6 @@ 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);
@@ -217,9 +209,8 @@ public class CashierDepositRequestApiResource {
@QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate) {
// A branch reviews only the requests its own counters raised (and those of branches below it).
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + REQUEST_BRANCH_IN_HIERARCHY);
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
final StringBuilder where = new StringBuilder("WHERE status = ?");
final List<Object> baseParams = new ArrayList<>(List.of(status));
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
baseParams.add(Date.valueOf(fromDate));
@@ -248,13 +239,9 @@ public class CashierDepositRequestApiResource {
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
final List<Map<String, Object>> pageItems = new ArrayList<>();
for (final Map<String, Object> row : rows) {
pageItems.add(withBranches(new java.util.LinkedHashMap<>(row)));
}
final JsonObject result = new JsonObject();
result.addProperty("totalFilteredRecords", total);
result.add("pageItems", gson.toJsonTree(pageItems));
result.add("pageItems", gson.toJsonTree(rows));
return gson.toJson(result);
}
@@ -319,82 +306,7 @@ public class CashierDepositRequestApiResource {
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String getById(@PathParam("id") final Long id) {
final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record);
final Map<String, Object> detail = new java.util.LinkedHashMap<>();
record.forEach((key, value) -> detail.put(String.valueOf(key), value));
return gson.toJson(withBranches(detail));
}
/**
* Adds the two branches a deposit request involves: where the cash was taken (the cashier's counter) and where the
* account is held. When they differ the request is an inter-branch deposit, which the reviewer must be able to see.
*/
private Map<String, Object> withBranches(final Map<String, Object> row) {
final Object requestId = row.get("id");
final List<Map<String, Object>> counter = jdbcTemplate.queryForList(
"SELECT o.id, o.name FROM m_cashier_deposit_request r LEFT JOIN m_appuser au ON au.id = r.cashier_id "
+ "JOIN m_office o ON o.id = COALESCE(r.counter_office_id, au.office_id) WHERE r.id = ?", requestId);
final List<Map<String, Object>> account = jdbcTemplate.queryForList("""
SELECT o.id, o.name FROM m_cashier_deposit_request r
LEFT JOIN m_savings_account sa ON sa.id = r.savings_account_id AND sa.account_no = r.account_no
LEFT JOIN m_client sc ON sc.id = sa.client_id
LEFT JOIN m_current_account ca ON ca.id = r.savings_account_id AND ca.account_no = r.account_no
LEFT JOIN m_client cc ON cc.id = ca.client_id
JOIN m_office o ON o.id = COALESCE(CASE WHEN r.account_type = 'GL' THEN r.office_id END,
sa.office_id, sc.office_id, ca.office_id, cc.office_id)
WHERE r.id = ?
""", requestId);
final Long counterId = counter.isEmpty() ? null : ((Number) counter.get(0).get("id")).longValue();
final Long accountId = account.isEmpty() ? null : ((Number) account.get(0).get("id")).longValue();
row.put("counter_office_name", counter.isEmpty() ? null : counter.get(0).get("name"));
row.put("account_office_name", account.isEmpty() ? null : account.get(0).get("name"));
row.put("counter_branch_id", counterId);
row.put("account_branch_id", accountId);
row.put("inter_branch", counterId != null && accountId != null && !counterId.equals(accountId));
return row;
}
/**
* A cash deposit posts in the account's branch, but the cash is in the drawer of the cashier who took it. When those
* are different branches, moves the cash line of the posting to the counter's branch through Liability Transfer.
* Only a cash payment has a cash line, and only a savings-table deposit (savings, FD, RD) is covered here.
*/
private void moveCashToCounterBranch(final Map<?, ?> record, final String txnId, final BigDecimal amount, final String txnDateStr) {
final Object paymentType = record.get("payment_type_name");
final Long savingsTxnId = parseTxnIdToLong(txnId);
if (savingsTxnId == null || paymentType == null || !"cash".equalsIgnoreCase(paymentType.toString().trim())) {
return;
}
final Map<String, Object> seed = new java.util.LinkedHashMap<>();
seed.put("id", record.get("id"));
final Map<String, Object> branches = withBranches(seed);
final Long counterId = (Long) branches.get("counter_branch_id");
final Long accountId = (Long) branches.get("account_branch_id");
if (counterId == null || accountId == null || counterId.equals(accountId)) {
return;
}
final java.time.LocalDate date = txnDateStr != null && !txnDateStr.isBlank() ? java.time.LocalDate.parse(txnDateStr)
: DateUtils.getBusinessLocalDate();
interBranchCashService.moveCashLegToBranch(savingsTxnId, accountId, counterId, String.valueOf(record.get("account_no")), amount,
date, true);
}
/**
* Rows whose branch (the counter that took the cash; the cashier's own office for requests raised before that was
* recorded) is at or below the caller's office. Takes the caller's hierarchy plus "%" as its one parameter.
*/
private static final String REQUEST_BRANCH_IN_HIERARCHY = "COALESCE(counter_office_id, "
+ "(SELECT au.office_id FROM m_appuser au WHERE au.id = cashier_id)) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)";
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
private void assertRequestInCallersBranch(final Map<?, ?> record) {
final Integer visible = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cashier_deposit_request WHERE id = ? AND " + REQUEST_BRANCH_IN_HIERARCHY, Integer.class,
((Number) record.get("id")).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
if (visible == null || visible == 0) {
throw new NotFoundException("Deposit request not found: " + record.get("id"));
}
return gson.toJson(loadRecord(id));
}
// ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ──
@@ -481,7 +393,6 @@ public class CashierDepositRequestApiResource {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
@@ -539,7 +450,6 @@ public class CashierDepositRequestApiResource {
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
};
if (!isCurrentAccount && txnId != null) {
moveCashToCounterBranch(record, txnId, amount, txnDateStr);
// approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
// hit an edge case in the new hold-placement logic would fail entirely, where before
@@ -620,7 +530,6 @@ public class CashierDepositRequestApiResource {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
@@ -20,7 +20,6 @@ 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;
@@ -37,7 +36,6 @@ 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();
/**
@@ -331,18 +329,7 @@ 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;
}
@@ -363,7 +350,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 && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
@@ -57,23 +57,9 @@ 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) {
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) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
@@ -116,9 +102,6 @@ public class CashTransferApiResource {
}
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
if (dryRun) {
return "{}";
}
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
@@ -129,9 +112,15 @@ public class CashTransferApiResource {
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
// Recorded against the person who raised the request, not the one who approved it.
header.setPerformedBy(performedBy);
header.setPerformedByUsername(performedByUsername);
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
}
// Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
@@ -170,171 +159,6 @@ 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,
@@ -577,11 +577,11 @@ public class ShareAdvanceCollectionApiResource {
}
// A separately-verified entry-fee receipt, when the member's fee was collected as its own
// receipt into whatever GL account the cashier actually deposited it to. This id proves the
// fee was paid and never contributes to the share-value leg below. If the cashier banked it
// into an income account it is left there; anywhere else (Sundry, most often) it is moved to
// Share Entry Fee Income below. Omit it (leave null) when the fee was paid as part of the same
// Sundry receipt(s) as the share value -- that single-receipt case is swept out of Sundry below.
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry.
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value --
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below.
final Long chargeCollectionId = getLong(json, "chargeCollectionId");
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
@@ -748,20 +748,6 @@ public class ShareAdvanceCollectionApiResource {
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
currencyCode, "Share entry fee for " + accountNo);
}
} else {
// A separate fee receipt is only already income if the cashier banked it into an income
// account. One deposited into Sundry (or any other non-income account) sat there for
// good: nothing here moved it, so the year-end close never swept it into profit. Move
// the fee out of wherever it actually sits; any over-payment on that receipt stays put,
// as before.
final Long feeReceiptGl = receiptGlOf((String) chargeColl.get("gl_transaction_id"), glSuspense);
if (!isIncomeGl(feeReceiptGl)) {
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, feeReceiptGl, DEBIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee settled | Receipt " + chargeColl.get("receipt_no") + " | " + accountNo);
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
}
}
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
@@ -239,11 +239,7 @@ public class ChequeBookIssuanceChargeService {
final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId)
: getCurrentControlGlId(accountId);
// A charge that names no income GL of its own falls back to the product's Income From Fees, the
// same order the savings charge bridge follows. Facility charges are seeded with no GL, so without
// this every cheque book was issued free and the fee never reached income or the year's profit.
final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId()
: getProductFeeIncomeGlId(accountType, accountId);
final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId() : null;
if (ownAccountGlId == null || incomeGlId == null) {
log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); "
+ "charge deduction skipped to avoid an unbalanced ledger.", chequeBookId, ownAccountGlId, incomeGlId);
@@ -349,19 +345,6 @@ public class ChequeBookIssuanceChargeService {
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
/** The account's product's Income From Fees mapping (financial_account_type 4), or null if the product maps none. */
private Long getProductFeeIncomeGlId(final ChequeAccountType accountType, final Long accountId) {
final String sql = accountType == ChequeAccountType.SAVINGS
? "SELECT apm.gl_account_id FROM m_savings_account sa JOIN acc_product_mapping apm ON apm.product_id = sa.product_id "
+ "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL "
+ "AND apm.payment_type IS NULL LIMIT 1"
: "SELECT apm.gl_account_id FROM m_current_account ca JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL "
+ "AND apm.payment_type IS NULL LIMIT 1";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, accountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
@@ -1,319 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cic.service.CicAlertService;
import org.apache.fineract.portfolio.cic.service.CicCatalog;
import org.apache.fineract.portfolio.cic.service.CicConfigService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* CIC / credit bureau: bureau whitelist and configuration, API mapping, and the enquiry alerts the CICs send when one of
* the bank's customers is enquired by another lender.
*
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICALERT (dashboard and register),
* UPDATE_CICALERT (work an alert), FETCH_CICALERT (fetch now / import a file).
*/
@Path("/v1/cic")
@Component
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping and enquiry alerts.")
@RequiredArgsConstructor
public class CicApiResource {
private final PlatformSecurityContext context;
private final CicConfigService configService;
private final CicAlertService alertService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── Catalog ───────────────────────────────────────────────────────────────
@GET
@Path("catalog")
@Produces(MediaType.APPLICATION_JSON)
public String catalog() {
context.authenticatedUser();
final Map<String, Object> c = new LinkedHashMap<>();
c.put("environments", CicCatalog.ENVIRONMENTS);
c.put("authTypes", CicCatalog.AUTH_TYPES);
c.put("endpointTypes", CicCatalog.ENDPOINT_TYPES);
c.put("httpMethods", CicCatalog.HTTP_METHODS);
c.put("cbsFields", CicCatalog.CBS_FIELDS);
c.put("resultFields", CicCatalog.RESULT_FIELDS);
c.put("rootFields", CicCatalog.ROOT_FIELDS);
c.put("transforms", CicCatalog.TRANSFORMS);
c.put("alertStatuses", CicCatalog.ALERT_STATUSES);
c.put("matchTypes", CicCatalog.MATCH_TYPES);
return gson.toJson(c);
}
// ── Bureaus ───────────────────────────────────────────────────────────────
@GET
@Path("bureaus")
@Produces(MediaType.APPLICATION_JSON)
public String bureaus(@QueryParam("whitelistedOnly") @DefaultValue("false") final boolean whitelistedOnly) {
requireAny("READ_CICBUREAU", "READ_CICALERT");
return gson.toJson(configService.listBureaus(whitelistedOnly));
}
@GET
@Path("bureaus/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String bureau(@PathParam("id") final Long id) {
require("READ_CICBUREAU");
return gson.toJson(configService.getBureau(id));
}
@PUT
@Path("bureaus/{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String updateBureau(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
return gson.toJson(configService.updateBureau(id, json(body), user));
}
@PUT
@Path("bureaus/{id}/whitelist")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String whitelist(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
final JsonObject in = json(body);
final boolean whitelisted = in.has("whitelisted") && !in.get("whitelisted").isJsonNull() && in.get("whitelisted").getAsBoolean();
return gson.toJson(configService.setWhitelisted(id, whitelisted, user));
}
@POST
@Path("bureaus/{id}/test-connection")
@Produces(MediaType.APPLICATION_JSON)
public String testConnection(@PathParam("id") final Long id) {
require("UPDATE_CICBUREAU");
return gson.toJson(configService.testConnection(id));
}
// ── API mapping ───────────────────────────────────────────────────────────
@GET
@Path("bureaus/{id}/endpoints/{type}")
@Produces(MediaType.APPLICATION_JSON)
public String endpoint(@PathParam("id") final Long id, @PathParam("type") final String type) {
require("READ_CICBUREAU");
return gson.toJson(configService.getEndpoint(id, type));
}
@PUT
@Path("bureaus/{id}/endpoints/{type}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String saveEndpoint(@PathParam("id") final Long id, @PathParam("type") final String type, final String body) {
final AppUser user = require("UPDATE_CICBUREAU");
return gson.toJson(configService.saveEndpoint(id, type, json(body), user));
}
@POST
@Path("bureaus/{id}/preview")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String preview(@PathParam("id") final Long id, final String body) {
require("READ_CICBUREAU");
return gson.toJson(alertService.preview(id, json(body)));
}
@POST
@Path("bureaus/{id}/parse-test")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String parseTest(@PathParam("id") final Long id, final String body) {
require("READ_CICBUREAU");
return gson.toJson(alertService.parseTest(id, json(body)));
}
// ── Fetch / import ────────────────────────────────────────────────────────
@POST
@Path("bureaus/{id}/fetch")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String fetch(@PathParam("id") final Long id, final String body) {
final AppUser user = require("FETCH_CICALERT");
final JsonObject in = json(body);
return gson.toJson(alertService.fetch(id, text(in, "fromDate"), text(in, "toDate"), "MANUAL", user));
}
@POST
@Path("bureaus/{id}/import")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String importFile(@PathParam("id") final Long id, final String body) {
final AppUser user = require("FETCH_CICALERT");
return gson.toJson(alertService.importFile(id, json(body), user));
}
@GET
@Path("fetch-runs")
@Produces(MediaType.APPLICATION_JSON)
public String fetchRuns(@QueryParam("bureauId") final Long bureauId, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
requireAny("READ_CICALERT", "READ_CICBUREAU");
return gson.toJson(alertService.fetchRuns(bureauId, Math.max(0, offset), clamp(limit)));
}
@GET
@Path("fetch-runs/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String fetchRun(@PathParam("id") final Long id) {
requireAny("READ_CICALERT", "READ_CICBUREAU");
return gson.toJson(alertService.fetchRun(id, true));
}
// ── Alerts ────────────────────────────────────────────────────────────────
@GET
@Path("dashboard")
@Produces(MediaType.APPLICATION_JSON)
public String dashboard(@QueryParam("days") @DefaultValue("30") final int days) {
require("READ_CICALERT");
return gson.toJson(alertService.dashboard(Math.max(1, Math.min(365, days))));
}
@GET
@Path("alerts")
@Produces(MediaType.APPLICATION_JSON)
public String alerts(@QueryParam("status") final String status, @QueryParam("match") final String match,
@QueryParam("clientId") final String clientId, @QueryParam("bureauId") final String bureauId,
@QueryParam("officeId") final String officeId, @QueryParam("loanType") final String loanType,
@QueryParam("institutionType") final String institutionType, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate, @QueryParam("minAmount") final String minAmount,
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
@QueryParam("limit") @DefaultValue("20") final int limit) {
require("READ_CICALERT");
final JsonObject f = new JsonObject();
f.addProperty("status", status);
f.addProperty("match", match);
f.addProperty("clientId", clientId);
f.addProperty("bureauId", bureauId);
f.addProperty("officeId", officeId);
f.addProperty("loanType", loanType);
f.addProperty("institutionType", institutionType);
f.addProperty("fromDate", fromDate);
f.addProperty("toDate", toDate);
f.addProperty("minAmount", minAmount);
f.addProperty("search", search);
return gson.toJson(alertService.list(f, Math.max(0, offset), Math.max(1, Math.min(5000, limit))));
}
@GET
@Path("alerts/options")
@Produces(MediaType.APPLICATION_JSON)
public String alertOptions() {
require("READ_CICALERT");
return gson.toJson(alertService.filterOptions());
}
@GET
@Path("alerts/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String alert(@PathParam("id") final Long id) {
require("READ_CICALERT");
return gson.toJson(alertService.get(id));
}
@PUT
@Path("alerts/{id}/status")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String updateStatus(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.updateStatus(id, json(body), user));
}
@PUT
@Path("alerts/{id}/client")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String linkClient(@PathParam("id") final Long id, final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.linkClient(id, json(body), user));
}
@POST
@Path("alerts/mark-seen")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String markSeen(final String body) {
final AppUser user = require("UPDATE_CICALERT");
return gson.toJson(alertService.markSeen(json(body), user));
}
// ── helpers ──────────────────────────────────────────────────────────────
private AppUser require(final String permission) {
final AppUser user = context.authenticatedUser();
user.validateHasPermissionTo(permission);
return user;
}
private AppUser requireAny(final String... permissions) {
final AppUser user = context.authenticatedUser();
if (user.hasAnyPermission("ALL_FUNCTIONS") || user.hasAnyPermission(permissions)) {
return user;
}
throw new NoAuthorizationException("User has no authority to " + String.join(" / ", permissions));
}
private static JsonObject json(final String body) {
if (StringUtils.isBlank(body)) {
return new JsonObject();
}
final JsonElement e = JsonParser.parseString(body);
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
}
private static String text(final JsonObject in, final String key) {
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsString() : null;
}
private static int clamp(final int limit) {
return Math.max(1, Math.min(200, limit));
}
}
@@ -1,89 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.domain.ActionContext;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Fetches enquiry alerts automatically: every few minutes, for every tenant, each whitelisted bureau with automatic
* fetch on is fetched once its interval has passed since the last fetch. Runs only on the batch-manager node.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CicAlertScheduler {
private final FineractProperties fineractProperties;
private final TenantDetailsService tenantDetailsService;
private final BusinessDateReadPlatformService businessDateReadPlatformService;
private final JdbcTemplate jdbcTemplate;
private final CicAlertService alertService;
@Scheduled(fixedDelayString = "${cbs.cic.alert-scheduler-delay-ms:300000}", initialDelayString = "${cbs.cic.alert-scheduler-initial-delay-ms:180000}")
public void fetchDueAlerts() {
if (!fineractProperties.getMode().isBatchManagerEnabled()) {
return;
}
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
try {
ThreadLocalContextUtil.setTenant(tenant);
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
SELECT id, code, alert_fetch_interval_min, last_fetch_on FROM m_cic_bureau
WHERE whitelisted = TRUE AND alert_auto_fetch = TRUE
""");
final long now = Instant.now().toEpochMilli();
for (final Map<String, Object> b : due) {
final long intervalMs = ((Number) b.get("alert_fetch_interval_min")).longValue() * 60_000L;
final Object last = b.get("last_fetch_on");
if (last instanceof Timestamp ts && now - ts.getTime() < intervalMs) {
continue;
}
try {
alertService.fetch(((Number) b.get("id")).longValue(), null, null, "SCHEDULED", null);
} catch (final RuntimeException e) {
// A bureau that is not ready (no URL, no credential ...) is reported on the run / bureau, not retried here.
log.warn("CIC scheduled alert fetch of {} for tenant {} failed: {}", b.get("code"), tenant.getTenantIdentifier(),
e.getMessage());
}
}
} catch (final RuntimeException e) {
// A tenant whose database does not have the CIC tables yet.
log.debug("CIC alert scheduler skipped tenant {}: {}", tenant.getTenantIdentifier(), e.getMessage());
} finally {
ThreadLocalContextUtil.reset();
}
}
}
}
@@ -1,215 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonPrimitive;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Everything the API Mapping screen can choose from for the ENQUIRY_ALERT call, and the transforms applied when a
* value is written into the bureau request or read back from the bureau response.
*/
public final class CicCatalog {
private CicCatalog() {}
public static final List<String> ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION");
public static final List<String> AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS");
public static final String ALERT_ENDPOINT = "ENQUIRY_ALERT";
public static final List<String> ENDPOINT_TYPES = List.of(ALERT_ENDPOINT);
public static final List<String> HTTP_METHODS = List.of("POST", "PUT", "GET");
public static final List<String> ALERT_STATUSES = List.of("NEW", "SEEN", "ACTIONED", "DISMISSED");
public static final List<String> MATCH_TYPES = List.of("PAN", "AADHAAR", "CUSTOMER_REF", "MANUAL", "NONE");
/** Values a REQUEST mapping can take: the fetch window and the bank's identity at the bureau. */
public static final Map<String, String> CBS_FIELDS = new LinkedHashMap<>();
static {
CBS_FIELDS.put("fetch.reference", "Fetch reference number");
CBS_FIELDS.put("fetch.fromDate", "Alerts from date");
CBS_FIELDS.put("fetch.toDate", "Alerts to date");
CBS_FIELDS.put("fetch.date", "Request date (business date)");
CBS_FIELDS.put("bureau.memberCode", "Bank's member code at the bureau");
CBS_FIELDS.put("bureau.username", "Bureau user id");
}
/**
* Result fields a RESPONSE mapping can fill. errorCode / errorMessage / alerts are read from the root of the
* response; "alerts" points at the list, and every other field is read from each item of that list.
*/
public static final Map<String, String> RESULT_FIELDS = new LinkedHashMap<>();
static {
RESULT_FIELDS.put("alerts", "List of alerts (array) - root");
RESULT_FIELDS.put("errorCode", "Bureau error code - root");
RESULT_FIELDS.put("errorMessage", "Bureau error message - root");
RESULT_FIELDS.put("alertRef", "Alert / enquiry id at the bureau");
RESULT_FIELDS.put("pan", "Customer PAN");
RESULT_FIELDS.put("aadhaar", "Customer Aadhaar (masked or last 4)");
RESULT_FIELDS.put("customerName", "Customer name");
RESULT_FIELDS.put("dateOfBirth", "Customer date of birth");
RESULT_FIELDS.put("customerRef", "Our customer reference (CIF / account no) reported to the bureau");
RESULT_FIELDS.put("institutionCode", "Enquiring lender's member code");
RESULT_FIELDS.put("institutionName", "Enquiring lender (other bank / NBFC)");
RESULT_FIELDS.put("institutionType", "Lender type (Bank / NBFC / MFI ...)");
RESULT_FIELDS.put("enquiryDate", "Enquiry date");
RESULT_FIELDS.put("loanType", "Loan type applied for");
RESULT_FIELDS.put("amount", "Loan amount applied for");
RESULT_FIELDS.put("purpose", "Enquiry purpose");
RESULT_FIELDS.put("location", "Branch / city of the enquiry");
}
/** Result fields read from the root of the response, not from each alert. */
public static final Set<String> ROOT_FIELDS = Set.of("alerts", "errorCode", "errorMessage");
public static final Map<String, String> TRANSFORMS = new LinkedHashMap<>();
static {
TRANSFORMS.put("NONE", "As is");
TRANSFORMS.put("UPPER", "UPPER CASE");
TRANSFORMS.put("LOWER", "lower case");
TRANSFORMS.put("TRIM", "Trim spaces");
TRANSFORMS.put("DIGITS_ONLY", "Digits only");
TRANSFORMS.put("DATE_DDMMYYYY", "Date ddMMyyyy");
TRANSFORMS.put("DATE_DD_MM_YYYY", "Date dd-MM-yyyy");
TRANSFORMS.put("DATE_DD_SLASH_MM_YYYY", "Date dd/MM/yyyy");
TRANSFORMS.put("DATE_YYYY_MM_DD", "Date yyyy-MM-dd");
TRANSFORMS.put("AMOUNT_INTEGER", "Amount, whole rupees");
TRANSFORMS.put("NUMBER", "Number");
TRANSFORMS.put("BOOLEAN", "true / false");
}
private static final Map<String, String> DATE_PATTERNS = Map.of("DATE_DDMMYYYY", "ddMMyyyy", "DATE_DD_MM_YYYY", "dd-MM-yyyy",
"DATE_DD_SLASH_MM_YYYY", "dd/MM/yyyy", "DATE_YYYY_MM_DD", "yyyy-MM-dd");
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
/** Applies a REQUEST transform to a CBS value; null stays null. Dates come in as ISO yyyy-MM-dd. */
public static JsonElement applyRequestTransform(final String transform, final Object raw) {
if (raw == null) {
return null;
}
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
final String s = String.valueOf(raw).trim();
if (s.isEmpty()) {
return null;
}
if (DATE_PATTERNS.containsKey(t)) {
try {
return new JsonPrimitive(LocalDate.parse(s.length() > 10 ? s.substring(0, 10) : s, ISO)
.format(DateTimeFormatter.ofPattern(DATE_PATTERNS.get(t))));
} catch (final RuntimeException e) {
return new JsonPrimitive(s);
}
}
switch (t) {
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
case "LOWER":
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
case "TRIM":
return new JsonPrimitive(s.replaceAll("\\s+", " "));
case "DIGITS_ONLY": {
final String digits = s.replaceAll("\\D", "");
return digits.isEmpty() ? null : new JsonPrimitive(digits);
}
case "AMOUNT_INTEGER":
return new JsonPrimitive(new BigDecimal(s).setScale(0, RoundingMode.HALF_UP).toBigInteger());
case "NUMBER":
return new JsonPrimitive(new BigDecimal(s).stripTrailingZeros());
case "BOOLEAN":
return new JsonPrimitive(Boolean.parseBoolean(s) || "1".equals(s) || "Y".equalsIgnoreCase(s));
default:
return new JsonPrimitive(s);
}
}
/**
* Converts a value read from a bureau response. A DATE_* transform names the format the bureau sends, and the
* value comes out as ISO yyyy-MM-dd. Arrays and objects pass through.
*/
public static JsonElement applyResponseTransform(final String transform, final JsonElement value) {
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
return value;
}
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
final String s = value.getAsString().trim();
try {
if (DATE_PATTERNS.containsKey(t)) {
return new JsonPrimitive(parseDate(s, DATE_PATTERNS.get(t)).toString());
}
switch (t) {
case "NUMBER":
case "AMOUNT_INTEGER": {
final String numeric = s.replaceAll("[^0-9.\\-]", "");
return numeric.isEmpty() ? null : new JsonPrimitive(new BigDecimal(numeric));
}
case "BOOLEAN":
return new JsonPrimitive("true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s));
case "UPPER":
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
case "LOWER":
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
case "TRIM":
return new JsonPrimitive(s.replaceAll("\\s+", " "));
case "DIGITS_ONLY":
return new JsonPrimitive(s.replaceAll("\\D", ""));
default:
return value;
}
} catch (final RuntimeException e) {
return value;
}
}
/** Parses a bureau date in the given pattern, falling back to ISO. */
public static LocalDate parseDate(final String s, final String pattern) {
final String v = s.trim();
try {
return LocalDate.parse(v, DateTimeFormatter.ofPattern(pattern));
} catch (final RuntimeException e) {
return LocalDate.parse(v.length() > 10 ? v.substring(0, 10) : v, ISO);
}
}
/** ABCDE1234F -> AB****234F style masking for screens and logs. */
public static String mask(final String value) {
if (value == null || value.length() <= 4) {
return value == null ? null : "****";
}
final int keepStart = Math.min(2, value.length() / 4);
final int keepEnd = Math.min(4, value.length() / 3);
return value.substring(0, keepStart) + "*".repeat(value.length() - keepStart - keepEnd) + value.substring(value.length() - keepEnd);
}
/** Aadhaar shown as XXXX-XXXX-1234 whatever form the bureau sends it in. */
public static String maskAadhaar(final String value) {
if (value == null) {
return null;
}
final String digits = value.replaceAll("\\D", "");
return digits.length() < 4 ? null : "XXXX-XXXX-" + digits.substring(digits.length() - 4);
}
}
@@ -1,463 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The bank's bureau master (whitelist, environment, connection, encrypted credentials) and each bureau's API mapping:
* endpoint, headers, base body and the REQUEST / RESPONSE field mappings.
*/
@Service
@RequiredArgsConstructor
public class CicConfigService {
private static final Pattern RESULT_KEY = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,99}$");
private final JdbcTemplate jdbcTemplate;
private final CicHttpGateway gateway;
// ── Bureaus ───────────────────────────────────────────────────────────────
/** Full row including the encrypted secret - internal use only, never returned by the API. */
public Map<String, Object> bureauRow(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_bureau WHERE id = ?", id);
if (rows.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.bureau.not.found", "Credit bureau " + id + " was not found.");
}
return rows.get(0);
}
public List<Map<String, Object>> listBureaus(final boolean whitelistedOnly) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(
"SELECT * FROM m_cic_bureau" + (whitelistedOnly ? " WHERE whitelisted = TRUE" : "") + " ORDER BY is_default DESC, id")) {
out.add(publicView(row));
}
return out;
}
public Map<String, Object> getBureau(final Long id) {
return publicView(bureauRow(id));
}
/** A bureau row as the screens see it: never the secret itself, only whether one is set. */
private Map<String, Object> publicView(final Map<String, Object> row) {
final Map<String, Object> v = new LinkedHashMap<>();
final Long id = ((Number) row.get("id")).longValue();
v.put("id", id);
v.put("code", row.get("code"));
v.put("name", row.get("name"));
v.put("description", row.get("description"));
v.put("whitelisted", row.get("whitelisted"));
v.put("isDefault", row.get("is_default"));
v.put("environment", row.get("environment"));
v.put("baseUrl", row.get("base_url"));
v.put("memberCode", row.get("member_code"));
v.put("authType", row.get("auth_type"));
v.put("username", row.get("username"));
v.put("apiKeyHeader", row.get("api_key_header"));
v.put("hasSecret", row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank());
v.put("tokenUrl", row.get("token_url"));
v.put("clientId", row.get("client_id"));
v.put("timeoutMs", row.get("timeout_ms"));
v.put("alertAutoFetch", row.get("alert_auto_fetch"));
v.put("alertFetchIntervalMin", row.get("alert_fetch_interval_min"));
v.put("alertLookbackDays", row.get("alert_lookback_days"));
v.put("lastFetchOn", row.get("last_fetch_on") == null ? null : row.get("last_fetch_on").toString());
v.put("lastFetchStatus", row.get("last_fetch_status"));
v.put("lastFetchMessage", row.get("last_fetch_message"));
v.put("lastTestStatus", row.get("last_test_status"));
v.put("lastTestMessage", row.get("last_test_message"));
v.put("lastTestedOn", row.get("last_tested_on") == null ? null : row.get("last_tested_on").toString());
v.put("updatedOn", row.get("updated_on") == null ? null : row.get("updated_on").toString());
v.put("activeEndpoints", jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, id));
v.put("alertsLast30Days", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND received_on >= ?",
Integer.class, id, Timestamp.from(Instant.now().minusSeconds(30L * 86400))));
v.put("newAlerts", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND status = 'NEW'",
Integer.class, id));
return v;
}
@Transactional
public Map<String, Object> updateBureau(final Long id, final JsonObject in, final AppUser user) {
final Map<String, Object> row = bureauRow(id);
final String environment = upper(text(in, "environment", (String) row.get("environment")));
if (!CicCatalog.ENVIRONMENTS.contains(environment)) {
throw fail("error.msg.cic.environment.invalid", "Environment must be one of " + CicCatalog.ENVIRONMENTS + ".");
}
final String authType = upper(text(in, "authType", (String) row.get("auth_type")));
if (!CicCatalog.AUTH_TYPES.contains(authType)) {
throw fail("error.msg.cic.auth.invalid", "Authentication must be one of " + CicCatalog.AUTH_TYPES + ".");
}
final String baseUrl = trimToNull(text(in, "baseUrl", (String) row.get("base_url")));
if (baseUrl != null && !StringUtils.startsWithAny(baseUrl, "https://", "http://")) {
throw fail("error.msg.cic.url.invalid", "Base URL must start with https:// (or http:// for a test server).");
}
if ("PRODUCTION".equals(environment) && baseUrl != null && !baseUrl.startsWith("https://")) {
throw fail("error.msg.cic.url.https", "A PRODUCTION bureau must be reached over https://.");
}
final String tokenUrl = trimToNull(text(in, "tokenUrl", (String) row.get("token_url")));
final int timeoutMs = intOf(in, "timeoutMs", ((Number) row.get("timeout_ms")).intValue());
if (timeoutMs < 1000 || timeoutMs > 300000) {
throw fail("error.msg.cic.timeout.invalid", "Timeout must be between 1,000 and 300,000 milliseconds.");
}
final boolean autoFetch = in.has("alertAutoFetch") && !in.get("alertAutoFetch").isJsonNull() ? in.get("alertAutoFetch").getAsBoolean()
: Boolean.TRUE.equals(row.get("alert_auto_fetch"));
final int interval = intOf(in, "alertFetchIntervalMin", ((Number) row.get("alert_fetch_interval_min")).intValue());
if (interval < 15 || interval > 1440) {
throw fail("error.msg.cic.interval.invalid", "Automatic fetch interval must be between 15 minutes and 24 hours (1440).");
}
final int lookback = intOf(in, "alertLookbackDays", ((Number) row.get("alert_lookback_days")).intValue());
if (lookback < 1 || lookback > 90) {
throw fail("error.msg.cic.lookback.invalid", "Look-back window must be between 1 and 90 days.");
}
String secretEnc = (String) row.get("secret_enc");
if (in.has("clearSecret") && in.get("clearSecret").getAsBoolean()) {
secretEnc = null;
} else if (in.has("secret") && !in.get("secret").isJsonNull() && !in.get("secret").getAsString().isBlank()) {
secretEnc = gateway.encrypt(in.get("secret").getAsString());
}
final boolean makeDefault = in.has("isDefault") && !in.get("isDefault").isJsonNull() && in.get("isDefault").getAsBoolean();
if (makeDefault) {
jdbcTemplate.update("UPDATE m_cic_bureau SET is_default = FALSE WHERE id <> ?", id);
}
jdbcTemplate.update("""
UPDATE m_cic_bureau SET name = ?, description = ?, environment = ?, base_url = ?, member_code = ?, auth_type = ?,
username = ?, api_key_header = ?, secret_enc = ?, token_url = ?, client_id = ?, timeout_ms = ?,
alert_auto_fetch = ?, alert_fetch_interval_min = ?, alert_lookback_days = ?, is_default = ?, updated_by = ?,
updated_on = ?
WHERE id = ?
""", StringUtils.defaultIfBlank(text(in, "name", (String) row.get("name")), (String) row.get("name")),
trimToNull(text(in, "description", (String) row.get("description"))), environment, baseUrl,
trimToNull(text(in, "memberCode", (String) row.get("member_code"))), authType,
trimToNull(text(in, "username", (String) row.get("username"))),
StringUtils.defaultIfBlank(text(in, "apiKeyHeader", (String) row.get("api_key_header")), "X-API-KEY"), secretEnc, tokenUrl,
trimToNull(text(in, "clientId", (String) row.get("client_id"))), timeoutMs, autoFetch, interval, lookback,
makeDefault || Boolean.TRUE.equals(row.get("is_default")), user.getId(), Timestamp.from(Instant.now()), id);
gateway.evictToken(id);
// A whitelisted bureau must stay usable after the edit; otherwise every alert fetch would fail silently.
if (Boolean.TRUE.equals(row.get("whitelisted"))) {
assertReadyForLive(bureauRow(id));
}
return getBureau(id);
}
@Transactional
public Map<String, Object> setWhitelisted(final Long id, final boolean whitelisted, final AppUser user) {
final Map<String, Object> row = bureauRow(id);
if (whitelisted) {
assertReadyForLive(row);
}
jdbcTemplate.update("UPDATE m_cic_bureau SET whitelisted = ?, updated_by = ?, updated_on = ? WHERE id = ?", whitelisted,
user.getId(), Timestamp.from(Instant.now()), id);
return getBureau(id);
}
/** What a bureau needs before alerts may be fetched from it. */
public void assertReadyForLive(final Map<String, Object> row) {
final String code = (String) row.get("code");
final Integer endpoints = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, row.get("id"));
if (endpoints == null || endpoints == 0) {
throw fail("error.msg.cic.no.endpoint", code + " has no active API endpoint. Configure it under API Mapping first.");
}
if ("SIMULATOR".equals(row.get("environment"))) {
return;
}
if (StringUtils.isBlank((String) row.get("base_url"))) {
throw fail("error.msg.cic.no.url", code + " has no base URL for " + row.get("environment") + ".");
}
final String auth = (String) row.get("auth_type");
final boolean hasSecret = row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank();
if (!"NONE".equals(auth) && !hasSecret) {
throw fail("error.msg.cic.no.secret", code + " uses " + auth + " but no password / key / secret is stored.");
}
if ("BASIC".equals(auth) && StringUtils.isBlank((String) row.get("username"))) {
throw fail("error.msg.cic.no.username", code + " uses BASIC authentication but has no username.");
}
if ("OAUTH2_CLIENT_CREDENTIALS".equals(auth)
&& (StringUtils.isBlank((String) row.get("token_url")) || StringUtils.isBlank((String) row.get("client_id")))) {
throw fail("error.msg.cic.no.oauth", code + " uses OAuth2 but the token URL or client id is missing.");
}
}
/** Reachability and credentials check; the result is kept on the bureau for the configuration screen. */
public Map<String, Object> testConnection(final Long id) {
final Map<String, Object> row = bureauRow(id);
String status;
String message;
if ("SIMULATOR".equals(row.get("environment"))) {
status = "OK";
message = "Simulator mode - no network call is made; alerts are answered by the built-in simulator.";
} else if (StringUtils.isBlank((String) row.get("base_url"))) {
status = "FAILED";
message = "No base URL configured.";
} else {
try {
if ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type"))) {
gateway.evictToken(id);
gateway.oauthToken(row);
}
final CicHttpGateway.HttpResult r = gateway.send(row, "GET", (String) row.get("base_url"), Map.of(), null, null);
if (r.status() == 0) {
status = "FAILED";
message = "Not reachable: " + r.error();
} else if (r.status() == 401 || r.status() == 403) {
status = "FAILED";
message = "Reachable, but the bureau refused the credentials (HTTP " + r.status() + ").";
} else {
status = "OK";
message = "Reachable (HTTP " + r.status() + " in " + r.durationMs() + " ms)"
+ ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type")) ? ", OAuth2 token obtained." : ".");
}
} catch (final IOException | RuntimeException e) {
status = "FAILED";
message = e.getMessage();
}
}
jdbcTemplate.update("UPDATE m_cic_bureau SET last_test_status = ?, last_test_message = ?, last_tested_on = ? WHERE id = ?", status,
StringUtils.abbreviate(message, 1000), Timestamp.from(Instant.now()), id);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("status", status);
res.put("message", message);
return res;
}
// ── Endpoints and mappings ────────────────────────────────────────────────
public Map<String, Object> endpointRow(final Long bureauId, final String type) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND endpoint_type = ?", bureauId, type);
return rows.isEmpty() ? null : rows.get(0);
}
public List<Map<String, Object>> mappingRows(final Long endpointId, final String direction) {
return jdbcTemplate.queryForList(
"SELECT * FROM m_cic_field_mapping WHERE endpoint_id = ? AND direction = ? ORDER BY sort_order, id", endpointId, direction);
}
public Map<String, Object> getEndpoint(final Long bureauId, final String type) {
bureauRow(bureauId);
final String t = upper(type);
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final Map<String, Object> e = endpointRow(bureauId, t);
final Map<String, Object> v = new LinkedHashMap<>();
v.put("bureauId", bureauId);
v.put("endpointType", t);
v.put("exists", e != null);
v.put("id", e == null ? null : e.get("id"));
v.put("httpMethod", e == null ? "POST" : e.get("http_method"));
v.put("path", e == null ? null : e.get("path"));
v.put("contentType", e == null ? "application/json" : e.get("content_type"));
v.put("headersJson", e == null ? "{}" : e.get("headers_json"));
v.put("baseBody", e == null ? "{}" : e.get("base_body"));
v.put("isActive", e == null ? Boolean.FALSE : e.get("is_active"));
v.put("requestMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "REQUEST")));
v.put("responseMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "RESPONSE")));
return v;
}
private static List<Map<String, Object>> mappingView(final List<Map<String, Object>> rows) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> m : rows) {
final Map<String, Object> v = new LinkedHashMap<>();
v.put("id", m.get("id"));
v.put("sourceField", m.get("source_field"));
v.put("targetField", m.get("target_field"));
v.put("transform", m.get("transform"));
v.put("defaultValue", m.get("default_value"));
v.put("mandatory", m.get("is_mandatory"));
v.put("sortOrder", m.get("sort_order"));
out.add(v);
}
return out;
}
/** Saves an endpoint and replaces its whole mapping in one transaction, after validating every row. */
@Transactional
public Map<String, Object> saveEndpoint(final Long bureauId, final String type, final JsonObject in, final AppUser user) {
final Map<String, Object> bureau = bureauRow(bureauId);
final String t = upper(type);
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
}
final String method = upper(StringUtils.defaultIfBlank(text(in, "httpMethod", "POST"), "POST"));
if (!CicCatalog.HTTP_METHODS.contains(method)) {
throw fail("error.msg.cic.method.invalid", "HTTP method must be one of " + CicCatalog.HTTP_METHODS + ".");
}
final String headersJson = StringUtils.defaultIfBlank(text(in, "headersJson", "{}"), "{}");
final String baseBody = StringUtils.defaultIfBlank(text(in, "baseBody", "{}"), "{}");
requireJsonObject(headersJson, "Headers");
requireJsonObject(baseBody, "Base request body");
for (final Map.Entry<String, JsonElement> h : JsonParser.parseString(headersJson).getAsJsonObject().entrySet()) {
if (!h.getValue().isJsonPrimitive()) {
throw fail("error.msg.cic.header.invalid", "Header '" + h.getKey() + "' must be a plain text value.");
}
}
final boolean active = !in.has("isActive") || in.get("isActive").isJsonNull() || in.get("isActive").getAsBoolean();
final List<Object[]> rows = new ArrayList<>();
validateMappings(in.has("requestMappings") ? in.getAsJsonArray("requestMappings") : new JsonArray(), "REQUEST", rows);
validateMappings(in.has("responseMappings") ? in.getAsJsonArray("responseMappings") : new JsonArray(), "RESPONSE", rows);
if (active && rows.stream().noneMatch(r -> "REQUEST".equals(r[0]))) {
throw fail("error.msg.cic.no.request.mapping", "An active endpoint needs at least one REQUEST mapping.");
}
if (active && rows.stream().noneMatch(r -> "RESPONSE".equals(r[0]) && "alerts".equals(r[2]))) {
throw fail("error.msg.cic.no.alert.list",
"Map the response path of the alert list to the result field 'alerts' (e.g. data.alerts), or the alerts cannot be read.");
}
Map<String, Object> existing = endpointRow(bureauId, t);
final Timestamp now = Timestamp.from(Instant.now());
if (existing == null) {
jdbcTemplate.update("""
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body,
is_active, updated_by, updated_on)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", bureauId, t, method, trimToNull(text(in, "path", null)),
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
user.getId(), now);
existing = endpointRow(bureauId, t);
} else {
jdbcTemplate.update("""
UPDATE m_cic_bureau_endpoint SET http_method = ?, path = ?, content_type = ?, headers_json = ?, base_body = ?, is_active = ?,
updated_by = ?, updated_on = ?
WHERE id = ?
""", method, trimToNull(text(in, "path", null)),
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
user.getId(), now, existing.get("id"));
}
final Long endpointId = ((Number) existing.get("id")).longValue();
jdbcTemplate.update("DELETE FROM m_cic_field_mapping WHERE endpoint_id = ?", endpointId);
for (final Object[] r : rows) {
jdbcTemplate.update("""
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory,
sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", endpointId, r[0], r[1], r[2], r[3], r[4], r[5], r[6]);
}
// Switching off the last active endpoint of a whitelisted bureau would leave it whitelisted but unusable.
if (Boolean.TRUE.equals(bureau.get("whitelisted"))) {
assertReadyForLive(bureauRow(bureauId));
}
return getEndpoint(bureauId, t);
}
private void validateMappings(final JsonArray array, final String direction, final List<Object[]> out) {
int order = 0;
for (final JsonElement el : array) {
final JsonObject m = el.getAsJsonObject();
order += 10;
final String source = trimToNull(text(m, "sourceField", null));
final String target = trimToNull(text(m, "targetField", null));
final String transform = StringUtils.defaultIfBlank(upper(text(m, "transform", "NONE")), "NONE");
final String defaultValue = trimToNull(text(m, "defaultValue", null));
final boolean mandatory = m.has("mandatory") && !m.get("mandatory").isJsonNull() && m.get("mandatory").getAsBoolean();
final int sortOrder = m.has("sortOrder") && !m.get("sortOrder").isJsonNull() ? m.get("sortOrder").getAsInt() : order;
final String label = direction + " row " + (order / 10);
if (!CicCatalog.TRANSFORMS.containsKey(transform)) {
throw fail("error.msg.cic.transform.invalid", label + ": unknown transform '" + transform + "'.");
}
if (target == null) {
throw fail("error.msg.cic.mapping.target.required", label + ": the target field is required.");
}
if ("REQUEST".equals(direction)) {
if (source == null && defaultValue == null) {
throw fail("error.msg.cic.mapping.source.required", label + " (" + target + "): choose a CBS field or give a fixed value.");
}
if (source != null && !CicCatalog.CBS_FIELDS.containsKey(source)) {
throw fail("error.msg.cic.mapping.source.invalid", label + ": unknown CBS field '" + source + "'.");
}
if (!CicJsonPath.isValid(target)) {
throw fail("error.msg.cic.mapping.path.invalid", label + ": '" + target + "' is not a valid JSON path (e.g. consumer.ids[0].number).");
}
} else {
if (source == null || !CicJsonPath.isValid(source)) {
throw fail("error.msg.cic.mapping.path.invalid",
label + ": the bureau response path is missing or invalid (e.g. report.score.value).");
}
if (!RESULT_KEY.matcher(target).matches()) {
throw fail("error.msg.cic.mapping.result.invalid", label + ": result field '" + target + "' must be a simple name.");
}
}
out.add(new Object[] { direction, source, target, transform, defaultValue, mandatory, sortOrder });
}
}
// ── helpers ──────────────────────────────────────────────────────────────
private static void requireJsonObject(final String text, final String label) {
try {
if (!JsonParser.parseString(text).isJsonObject()) {
throw new IllegalArgumentException();
}
} catch (final RuntimeException e) {
throw fail("error.msg.cic.json.invalid", label + " must be a valid JSON object.");
}
}
static String text(final JsonObject in, final String key, final String fallback) {
if (in == null || !in.has(key)) {
return fallback;
}
return in.get(key).isJsonNull() ? null : in.get(key).getAsString();
}
private static int intOf(final JsonObject in, final String key, final int fallback) {
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsInt() : fallback;
}
private static String upper(final String s) {
return s == null ? null : s.trim().toUpperCase();
}
private static String trimToNull(final String s) {
return StringUtils.trimToNull(s);
}
static GeneralPlatformDomainRuleException fail(final String code, final String message) {
return new GeneralPlatformDomainRuleException(code, message);
}
}
@@ -1,194 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.FormBody;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor;
import org.springframework.stereotype.Service;
/**
* Outbound HTTP to a credit bureau: URL joining, authentication (Basic, API key, bearer, OAuth2 client credentials
* with an in-memory token cache), per-bureau timeouts. The bureau's secret is stored encrypted and is only decrypted
* here, for the call itself.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CicHttpGateway {
private static final int MAX_BODY_CHARS = 2_000_000;
private final DatabasePasswordEncryptor encryptor;
private final Map<Long, CachedToken> tokens = new ConcurrentHashMap<>();
private record CachedToken(String token, Instant expiresAt) {}
/** What came back from the bureau. A transport failure is a status of 0 with the error in {@code body}. */
public record HttpResult(int status, String body, long durationMs, String error) {
public boolean ok() {
return status >= 200 && status < 300;
}
}
public String encrypt(final String plain) {
return plain == null ? null : encryptor.encrypt(plain);
}
private String secretOf(final Map<String, Object> bureau) {
final Object enc = bureau.get("secret_enc");
if (enc == null || enc.toString().isBlank()) {
return null;
}
try {
return encryptor.decrypt(enc.toString());
} catch (final RuntimeException e) {
throw new GeneralPlatformDomainRuleException("error.msg.cic.secret.unreadable",
"The stored credential of " + bureau.get("code") + " cannot be decrypted (was the master password changed?)."
+ " Re-enter it on the bureau configuration.");
}
}
public static String joinUrl(final String baseUrl, final String path) {
if (StringUtils.startsWithAny(path, "http://", "https://")) {
return path;
}
final String base = StringUtils.defaultString(baseUrl).replaceAll("/+$", "");
if (StringUtils.isBlank(path)) {
return base;
}
return base + (path.startsWith("/") ? path : "/" + path);
}
private OkHttpClient client(final Map<String, Object> bureau) {
final int timeout = bureau.get("timeout_ms") instanceof Number n && n.intValue() > 0 ? n.intValue() : 30000;
return new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS).readTimeout(timeout, TimeUnit.MILLISECONDS)
.writeTimeout(timeout, TimeUnit.MILLISECONDS).build();
}
/** Sends one request to the bureau with its configured authentication. */
public HttpResult send(final Map<String, Object> bureau, final String method, final String url, final Map<String, String> headers,
final String contentType, final String body) {
final long started = System.currentTimeMillis();
try {
final Request.Builder rb = new Request.Builder().url(url);
headers.forEach((k, v) -> {
if (StringUtils.isNotBlank(k) && v != null) {
rb.header(k, v);
}
});
applyAuth(bureau, rb);
final String m = StringUtils.defaultIfBlank(method, "POST").toUpperCase();
if ("GET".equals(m)) {
rb.get();
} else {
final MediaType mediaType = MediaType.parse(StringUtils.defaultIfBlank(contentType, "application/json"));
rb.method(m, RequestBody.create(StringUtils.defaultString(body), mediaType));
}
try (Response response = client(bureau).newCall(rb.build()).execute()) {
return new HttpResult(response.code(), read(response.body()), System.currentTimeMillis() - started, null);
}
} catch (final IOException | IllegalArgumentException e) {
log.warn("CIC {}: call to {} failed: {}", bureau.get("code"), url, e.getMessage());
return new HttpResult(0, null, System.currentTimeMillis() - started, e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
private static String read(final ResponseBody body) throws IOException {
if (body == null) {
return null;
}
final String text = new String(body.bytes(), StandardCharsets.UTF_8);
return text.length() > MAX_BODY_CHARS ? text.substring(0, MAX_BODY_CHARS) : text;
}
private void applyAuth(final Map<String, Object> bureau, final Request.Builder rb) throws IOException {
final String auth = StringUtils.defaultIfBlank((String) bureau.get("auth_type"), "NONE");
final String username = (String) bureau.get("username");
switch (auth) {
case "BASIC" -> {
final String raw = StringUtils.defaultString(username) + ":" + StringUtils.defaultString(secretOf(bureau));
rb.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)));
}
case "API_KEY" -> rb.header(StringUtils.defaultIfBlank((String) bureau.get("api_key_header"), "X-API-KEY"),
StringUtils.defaultString(secretOf(bureau)));
case "BEARER_TOKEN" -> rb.header("Authorization", "Bearer " + StringUtils.defaultString(secretOf(bureau)));
case "OAUTH2_CLIENT_CREDENTIALS" -> rb.header("Authorization", "Bearer " + oauthToken(bureau));
default -> {
// NONE: the bureau authenticates by IP whitelisting / mutual TLS at the network layer.
}
}
}
/** OAuth2 client-credentials token, cached until a minute before it expires. */
public String oauthToken(final Map<String, Object> bureau) throws IOException {
final Long id = ((Number) bureau.get("id")).longValue();
final CachedToken cached = tokens.get(id);
if (cached != null && Instant.now().isBefore(cached.expiresAt())) {
return cached.token();
}
final String tokenUrl = (String) bureau.get("token_url");
if (StringUtils.isBlank(tokenUrl)) {
throw new IOException("OAuth2 token URL is not configured");
}
final FormBody form = new FormBody.Builder().add("grant_type", "client_credentials")
.add("client_id", StringUtils.defaultString((String) bureau.get("client_id")))
.add("client_secret", StringUtils.defaultString(secretOf(bureau))).build();
try (Response response = client(bureau).newCall(new Request.Builder().url(tokenUrl).post(form).build()).execute()) {
final String text = read(response.body());
if (!response.isSuccessful()) {
throw new IOException("Token endpoint returned HTTP " + response.code() + ": " + StringUtils.abbreviate(text, 300));
}
final JsonElement json = JsonParser.parseString(StringUtils.defaultIfBlank(text, "{}"));
final JsonObject obj = json.isJsonObject() ? json.getAsJsonObject() : new JsonObject();
if (!obj.has("access_token")) {
throw new IOException("Token endpoint response has no access_token");
}
final long expiresIn = obj.has("expires_in") ? obj.get("expires_in").getAsLong() : 300L;
final String token = obj.get("access_token").getAsString();
tokens.put(id, new CachedToken(token, Instant.now().plusSeconds(Math.max(30, expiresIn - 60))));
return token;
}
}
/** Forget a cached token (after the bureau's credentials change). */
public void evictToken(final Long bureauId) {
tokens.remove(bureauId);
}
}
@@ -1,133 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Minimal dotted JSON path used by the bureau API mapping: {@code consumer.name.firstName},
* {@code consumer.ids[0].number}, {@code report.score.value}. Setting creates the missing objects and arrays on the
* way; reading returns null for anything absent.
*/
public final class CicJsonPath {
private static final Pattern SEGMENT = Pattern.compile("^([^\\[\\]]*)((?:\\[\\d+\\])*)$");
private static final Pattern INDEX = Pattern.compile("\\[(\\d+)\\]");
private CicJsonPath() {}
/** One step of a path: an object key, optionally followed by array indexes. */
private record Step(String key, List<Integer> indexes) {}
private static List<Step> parse(final String path) {
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("Empty JSON path");
}
final List<Step> steps = new ArrayList<>();
for (final String raw : path.trim().split("\\.")) {
final Matcher m = SEGMENT.matcher(raw);
if (!m.matches()) {
throw new IllegalArgumentException("Invalid JSON path segment '" + raw + "' in '" + path + "'");
}
final List<Integer> indexes = new ArrayList<>();
final Matcher im = INDEX.matcher(m.group(2));
while (im.find()) {
indexes.add(Integer.parseInt(im.group(1)));
}
steps.add(new Step(m.group(1), indexes));
}
return steps;
}
/** Whether the path is syntactically valid. */
public static boolean isValid(final String path) {
try {
parse(path);
return true;
} catch (final IllegalArgumentException e) {
return false;
}
}
public static JsonElement get(final JsonElement root, final String path) {
JsonElement current = root;
for (final Step step : parse(path)) {
if (!step.key().isEmpty()) {
if (current == null || !current.isJsonObject()) {
return null;
}
current = current.getAsJsonObject().get(step.key());
}
for (final Integer index : step.indexes()) {
if (current == null || !current.isJsonArray() || current.getAsJsonArray().size() <= index) {
return null;
}
current = current.getAsJsonArray().get(index);
}
}
return current == null || current.isJsonNull() ? null : current;
}
public static void set(final JsonObject root, final String path, final JsonElement value) {
final List<Step> steps = parse(path);
JsonElement current = root;
for (int s = 0; s < steps.size(); s++) {
final Step step = steps.get(s);
final boolean lastStep = s == steps.size() - 1;
if (!step.key().isEmpty()) {
final JsonObject obj = current.getAsJsonObject();
if (lastStep && step.indexes().isEmpty()) {
obj.add(step.key(), value == null ? JsonNull.INSTANCE : value);
return;
}
JsonElement next = obj.get(step.key());
if (next == null || next.isJsonNull()) {
next = step.indexes().isEmpty() ? new JsonObject() : new JsonArray();
obj.add(step.key(), next);
}
current = next;
}
for (int i = 0; i < step.indexes().size(); i++) {
final int index = step.indexes().get(i);
final boolean lastIndex = lastStep && i == step.indexes().size() - 1;
final JsonArray array = current.getAsJsonArray();
while (array.size() <= index) {
array.add(JsonNull.INSTANCE);
}
if (lastIndex) {
array.set(index, value == null ? JsonNull.INSTANCE : value);
return;
}
JsonElement next = array.get(index);
if (next == null || next.isJsonNull()) {
next = i < step.indexes().size() - 1 ? new JsonArray() : new JsonObject();
array.set(index, next);
}
current = next;
}
}
}
}
@@ -1,153 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cic.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Random;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Stand-in CIC for the SIMULATOR environment. Plays the bureau's enquiry-alert service: for every day of the asked
* window it reports a few of this bank's own customers as enquired by other lenders, in the shape of the seeded generic
* mapping. Deterministic per bureau and day, so fetching the same window again yields the same alerts (and they are
* de-duplicated). Now and then it also sends an alert for an unknown customer and one raised by this bank itself, so
* the unmatched and own-bank paths can be seen too. Never used once the bureau is set to UAT or PRODUCTION.
*/
@Component
@RequiredArgsConstructor
public class CicSimulator {
private static final String[][] LENDERS = { { "HDFC BANK LTD", "BANK" }, { "STATE BANK OF INDIA", "BANK" }, { "ICICI BANK LTD", "BANK" },
{ "BAJAJ FINANCE LTD", "NBFC" }, { "MUTHOOT FINANCE LTD", "NBFC" }, { "UJJIVAN SMALL FINANCE BANK", "SMALL FINANCE BANK" },
{ "BANDHAN BANK LTD", "BANK" }, { "SATIN CREDITCARE NETWORK", "MFI" }, { "MADHYA PRADESH GRAMIN BANK", "REGIONAL RURAL BANK" },
{ "SARASWAT CO-OPERATIVE BANK", "CO-OPERATIVE BANK" }, { "TATA CAPITAL LTD", "NBFC" }, { "PUNJAB NATIONAL BANK", "BANK" } };
private static final Object[][] LOAN_TYPES = { { "PERSONAL LOAN", 50_000, 1_000_000 }, { "HOUSING LOAN", 800_000, 6_000_000 },
{ "AUTO LOAN", 300_000, 1_500_000 }, { "TWO-WHEELER LOAN", 50_000, 200_000 }, { "GOLD LOAN", 20_000, 500_000 },
{ "BUSINESS LOAN", 200_000, 5_000_000 }, { "CREDIT CARD", 25_000, 300_000 }, { "KISAN CREDIT CARD", 50_000, 300_000 },
{ "EDUCATION LOAN", 200_000, 2_000_000 }, { "CONSUMER DURABLE LOAN", 10_000, 150_000 },
{ "LOAN AGAINST PROPERTY", 500_000, 5_000_000 }, { "MICROFINANCE LOAN", 20_000, 100_000 } };
private static final String[] CITIES = { "BHOPAL", "INDORE", "VIDISHA", "SAGAR", "JABALPUR", "GWALIOR", "BASODA", "RAISEN" };
private static final DateTimeFormatter DDMMYYYY = DateTimeFormatter.ofPattern("ddMMyyyy");
private final JdbcTemplate jdbcTemplate;
public JsonObject respond(final Map<String, Object> bureau, final LocalDate from, final LocalDate to, final String reference) {
final String code = String.valueOf(bureau.get("code"));
final String ownMemberCode = bureau.get("member_code") == null ? "OWNBANK" : bureau.get("member_code").toString();
final List<Map<String, Object>> clients = jdbcTemplate.queryForList("""
SELECT c.id, c.display_name, c.account_no, c.date_of_birth,
(SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL
ORDER BY k.id DESC LIMIT 1) AS pan,
(SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id
AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar
FROM m_client c
WHERE c.status_enum = 300
ORDER BY c.id
LIMIT 300
""");
final JsonArray alerts = new JsonArray();
final long days = Math.min(90, ChronoUnit.DAYS.between(from, to));
for (int d = 0; d <= days; d++) {
final LocalDate day = from.plusDays(d);
final Random r = new Random(Math.abs((long) (code + "|" + day).hashCode()));
final int count = clients.isEmpty() ? 0 : r.nextInt(4);
int seq = 0;
for (int i = 0; i < count; i++) {
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, null));
}
if (r.nextInt(8) == 0) {
// A consumer the bureau has on file for this bank but whom the bank no longer has (or never had) under that id.
alerts.add(alert(code, day, ++seq, Map.of("display_name", "UNKNOWN CUSTOMER", "pan", "ZZZPZ" + (1000 + r.nextInt(8999)) + "Z"),
r, null));
}
if (!clients.isEmpty() && r.nextInt(10) == 0) {
// An enquiry this bank made itself - the bureau reports it, the CBS must skip it.
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, ownMemberCode));
}
}
final JsonObject header = new JsonObject();
header.addProperty("status", "SUCCESS");
header.addProperty("referenceNumber", reference);
header.addProperty("bureau", code);
header.addProperty("fromDate", from.format(DDMMYYYY));
header.addProperty("toDate", to.format(DDMMYYYY));
final JsonObject data = new JsonObject();
data.addProperty("totalAlerts", alerts.size());
data.add("alerts", alerts);
final JsonObject root = new JsonObject();
root.add("responseHeader", header);
root.add("data", data);
return root;
}
private static JsonObject alert(final String code, final LocalDate day, final int seq, final Map<String, Object> client, final Random r,
final String forcedMemberCode) {
final JsonObject consumer = new JsonObject();
consumer.addProperty("name", String.valueOf(client.get("display_name")).toUpperCase());
final Object pan = client.get("pan");
final Object aadhaar = client.get("aadhaar");
if (pan != null && !pan.toString().isBlank()) {
consumer.addProperty("pan", pan.toString().trim().toUpperCase());
} else if (aadhaar != null && !aadhaar.toString().isBlank()) {
consumer.addProperty("aadhaar", "XXXXXXXX" + lastFour(aadhaar.toString()));
} else if (client.get("account_no") != null) {
consumer.addProperty("memberReference", client.get("account_no").toString());
}
if (client.get("date_of_birth") != null) {
consumer.addProperty("dateOfBirth", LocalDate.parse(client.get("date_of_birth").toString().substring(0, 10)).format(DDMMYYYY));
}
final int lenderIdx = r.nextInt(LENDERS.length);
final Object[] type = LOAN_TYPES[r.nextInt(LOAN_TYPES.length)];
final int min = (Integer) type[1];
final int max = (Integer) type[2];
final long amount = Math.round((min + r.nextDouble() * (max - min)) / 5000.0) * 5000L;
final JsonObject enquiry = new JsonObject();
enquiry.addProperty("memberCode", forcedMemberCode != null ? forcedMemberCode : "SIM" + String.format("%04d", 101 + lenderIdx));
enquiry.addProperty("memberName", forcedMemberCode != null ? "THIS BANK" : LENDERS[lenderIdx][0]);
enquiry.addProperty("memberType", forcedMemberCode != null ? "BANK" : LENDERS[lenderIdx][1]);
enquiry.addProperty("date", day.format(DDMMYYYY));
enquiry.addProperty("productType", (String) type[0]);
enquiry.addProperty("amount", amount);
enquiry.addProperty("purpose", r.nextInt(5) == 0 ? "LOAN RENEWAL / ENHANCEMENT" : "NEW LOAN APPLICATION");
enquiry.addProperty("location", CITIES[r.nextInt(CITIES.length)]);
final JsonObject a = new JsonObject();
a.addProperty("alertId", code + "-" + day.format(DDMMYYYY) + "-" + seq);
a.add("consumer", consumer);
a.add("enquiry", enquiry);
return a;
}
private static String lastFour(final String value) {
final String digits = value.replaceAll("\\D", "");
return digits.length() >= 4 ? digits.substring(digits.length() - 4) : "0000";
}
}
@@ -69,8 +69,6 @@ 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;
@@ -92,8 +90,6 @@ 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 ────────
@@ -206,9 +202,6 @@ 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) {
@@ -305,10 +298,6 @@ public class ClerkDisbursementRequestApiResource {
final StringBuilder where = new StringBuilder("WHERE status = ?");
final List<Object> baseParams = new ArrayList<>(List.of(status));
// A request belongs to the branch it was raised at. A checker sees their own branch's requests and those of the
// branches below it; Head Office, the root, sees every branch's.
where.append(" AND office_id IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)");
baseParams.add(currentUser.getOffice().getHierarchy() + "%");
if (fromDate != null && !fromDate.isBlank()) {
try {
where.append(" AND DATE(submitted_at) >= ?");
@@ -435,63 +424,8 @@ public class ClerkDisbursementRequestApiResource {
if (!currentUser.getId().equals(clerkId)) {
throw apiError(403, "You can only view your own disbursement requests.");
}
} else {
assertRequestInCallersBranch(record, currentUser);
}
return gson.toJson(withLegBranches(record));
}
/**
* The request as stored, with the branch each account leg is held at added to its entries.
*
* <p>
* The approver has to see when money moves between branches, and the stored entries carry only the account number
* and name. The branch is looked up now, from the account, rather than stored at submission: a request raised before
* this existed still shows it, and it is the branch the account is in at the moment of approval that matters. A
* leg with no account behind it (a GL leg) gets none, and a failure here leaves the entries exactly as stored, so
* the review screen never fails over it.
*/
private Map<String, Object> withLegBranches(final Map<?, ?> record) {
final Map<String, Object> out = new java.util.LinkedHashMap<>();
record.forEach((key, value) -> out.put(String.valueOf(key), value));
final Object stored = out.get("credit_entries");
if (!(stored instanceof String) || ((String) stored).isBlank()) {
return out;
}
try {
final JsonArray entries = gson.fromJson((String) stored, JsonArray.class);
for (final JsonElement element : entries) {
final JsonObject entry = element.getAsJsonObject();
final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(accountType) || !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final Map<String, Object> branch = branchOfAccount(accountType, entry.get("savingsAccountId").getAsLong());
if (branch != null) {
entry.addProperty("officeId", ((Number) branch.get("id")).longValue());
entry.addProperty("officeName", String.valueOf(branch.get("name")));
}
}
out.put("credit_entries", gson.toJson(entries));
} catch (final RuntimeException e) {
log.warn("Could not add account branches to clerk request {}: {}", record.get("id"), e.getMessage());
}
return out;
}
/**
* The branch an account's books are in: its own office when it was opened at another branch than the client's,
* otherwise the client's (or group's). A CURRENT leg carries an id in m_current_account's id space; every other
* non-GL type is a m_savings_account row.
*/
private Map<String, Object> branchOfAccount(final String accountType, final Long accountId) {
final String sql = "CURRENT".equalsIgnoreCase(accountType)
? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id "
+ "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?"
: "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id "
+ "LEFT JOIN m_group g ON g.id = sa.group_id "
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
return jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null);
return gson.toJson(record);
}
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
@@ -507,7 +441,6 @@ public class ClerkDisbursementRequestApiResource {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record, currentUser);
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
@@ -558,10 +491,6 @@ 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.
@@ -638,23 +567,21 @@ public class ClerkDisbursementRequestApiResource {
"No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
// Each leg is booked in the branch its own account is held at, not the request's branch.
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
if ("Dr".equals(dbCr)) {
final long txnId = isCurrent
? directDebitCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description);
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
btDebitGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
btTotalDr = btTotalDr.add(entryAmt);
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
} else {
final long txnId = isCurrent
? directCreditCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser);
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
btCreditGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent));
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
}
@@ -664,10 +591,13 @@ public class ClerkDisbursementRequestApiResource {
if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) {
final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description)
+ " → " + buildGlEntryComment("", btCreditGlEntries, null);
final String btGlTxnId = postBankTransferJournals(
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
// The request row keeps 60 characters; an inter-branch transfer posts one entry per branch.
approvedGlTxnId = btGlTxnId == null || btGlTxnId.length() <= 60 ? btGlTxnId : btGlTxnId.split(",")[0];
final String btGlTxnId = postCombinedJournalEntry(
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment);
if (btGlTxnId != null) {
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2);
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
}
approvedGlTxnId = btGlTxnId;
// Audit log in m_bank_transfer_log
final String btTxnRefsStr = String.join(",", txnRefs);
@@ -843,12 +773,6 @@ 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.
@@ -914,7 +838,6 @@ public class ClerkDisbursementRequestApiResource {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record, currentUser);
// Maker-checker: the person who submitted the request cannot also reject it.
final Long makerId = record.get("clerk_id") != null
@@ -1065,14 +988,12 @@ public class ClerkDisbursementRequestApiResource {
"No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
// Each leg is booked in the branch its own account is held at, not the request's branch.
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
final long txnId = isCurrent
? directDebitCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description);
? directDebitCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
debitGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
} else {
@@ -1094,12 +1015,11 @@ public class ClerkDisbursementRequestApiResource {
"No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
final long txnId = isCurrent
? directCreditCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser);
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
creditGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
} else {
@@ -1110,8 +1030,13 @@ public class ClerkDisbursementRequestApiResource {
// ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─
final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description)
+ " → " + buildGlEntryComment("", creditGlEntries, null);
glTxnId = postBankTransferJournals(
debitGlEntries, creditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
glTxnId = postCombinedJournalEntry(
debitGlEntries, creditGlEntries,
officeId, currencyCode, txnDateStr, btComment);
if (glTxnId != null) {
linkGlEntriesToSavingsAccounts(glTxnId, debitGlEntries, 2);
linkGlEntriesToSavingsAccounts(glTxnId, creditGlEntries, 1);
}
} catch (Exception ex) {
for (int i = undoOps.size() - 1; i >= 0; i--) {
@@ -1298,70 +1223,6 @@ 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;
@@ -1504,28 +1365,6 @@ public class ClerkDisbursementRequestApiResource {
}
}
/**
* A request belongs to the branch it was raised at, and is the business of that branch's checker, not another's.
* A checker may see and act on requests of their own branch and the branches below it; Head Office, as the root,
* reaches every branch. Anything outside that is reported as not found rather than forbidden, so it does not even
* confirm the request exists. A request with no branch on record belongs to the root.
*/
private void assertRequestInCallersBranch(final Map<?, ?> record, final AppUser user) {
final String callerHierarchy = user.getOffice().getHierarchy();
final Object officeId = record.get("office_id");
boolean visible;
if (officeId == null) {
visible = ".".equals(callerHierarchy);
} else {
final String requestHierarchy = jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class,
((Number) officeId).longValue()).stream().findFirst().orElse(null);
visible = requestHierarchy != null && requestHierarchy.startsWith(callerHierarchy);
}
if (!visible) {
throw new NotFoundException("Disbursement request not found: " + record.get("id"));
}
}
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
@@ -1770,8 +1609,6 @@ public class ClerkDisbursementRequestApiResource {
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
/** The branch this leg's account is held at; null where it was not resolved (the request's branch is then used). */
Long officeId;
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId) {
@@ -2205,105 +2042,6 @@ public class ClerkDisbursementRequestApiResource {
return result != null ? result.getTransactionId() : null;
}
/** Financial Activity "Liability Transfer": the account that links branches' books on a transfer between them. */
private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200;
/** The branch an account's books are in, or {@code fallbackOfficeId} where it cannot be resolved. */
private Long legOfficeOf(final boolean isCurrent, final Long accountId, final Long fallbackOfficeId) {
final Map<String, Object> branch = branchOfAccount(isCurrent ? "CURRENT" : "SAVINGS", accountId);
return branch != null ? ((Number) branch.get("id")).longValue() : fallbackOfficeId;
}
private static GlEntry withOffice(final GlEntry entry, final Long officeId) {
entry.officeId = officeId;
return entry;
}
private Long liabilityTransferGlId() {
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?",
Long.class, FINANCIAL_ACTIVITY_LIABILITY_TRANSFER);
if (ids.isEmpty()) {
throw new RuntimeException("A transfer between branches needs the Liability Transfer account mapped under "
+ "Accounting > Financial Activity Mappings.");
}
return ids.get(0);
}
private static BigDecimal totalOf(final List<GlEntry> entries) {
BigDecimal total = BigDecimal.ZERO;
for (final GlEntry entry : entries) {
total = total.add(entry.amount);
}
return total;
}
/**
* Posts the ledger side of a bank transfer and returns its transaction id, or the ids comma-separated when more than
* one entry is needed.
*
* <p>
* Every leg belongs to the branch its own account is held at. When they all share one branch this is the single
* combined entry it always was, booked in that branch. When the legs span branches, each branch gets its own entry,
* balanced through the Liability Transfer account: a branch that pays out more than it takes in credits the difference
* to it, one that takes in more debits it. The two sides net to zero across the bank, so each branch's books balance
* by themselves and the bank's total does not move. Each entry registers its own undo, so a failure later in the
* transfer rolls the ledger back with the accounts.
*/
private String postBankTransferJournals(final List<GlEntry> debits, final List<GlEntry> credits, final Long fallbackOfficeId,
final String currencyCode, final String txnDateStr, final String comment, final List<Runnable> undoOps) {
final Map<Long, List<GlEntry>> debitsByOffice = new java.util.LinkedHashMap<>();
final Map<Long, List<GlEntry>> creditsByOffice = new java.util.LinkedHashMap<>();
for (final GlEntry debit : debits) {
debitsByOffice.computeIfAbsent(debit.officeId != null ? debit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(debit);
}
for (final GlEntry credit : credits) {
creditsByOffice.computeIfAbsent(credit.officeId != null ? credit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(credit);
}
final Set<Long> offices = new LinkedHashSet<>(debitsByOffice.keySet());
offices.addAll(creditsByOffice.keySet());
final List<String> txnIds = new ArrayList<>();
if (offices.size() == 1) {
postBranchEntry(debits, credits, offices.iterator().next(), currencyCode, txnDateStr, comment, undoOps, txnIds);
} else {
final Long suspenseGlId = liabilityTransferGlId();
for (final Long office : offices) {
final List<GlEntry> branchDebits = new ArrayList<>(debitsByOffice.getOrDefault(office, List.of()));
final List<GlEntry> branchCredits = new ArrayList<>(creditsByOffice.getOrDefault(office, List.of()));
final BigDecimal net = totalOf(branchCredits).subtract(totalOf(branchDebits));
if (net.signum() > 0) {
branchDebits.add(new GlEntry(suspenseGlId, net, "Inter-branch", null));
} else if (net.signum() < 0) {
branchCredits.add(new GlEntry(suspenseGlId, net.negate(), "Inter-branch", null));
}
postBranchEntry(branchDebits, branchCredits, office, currencyCode, txnDateStr,
comment + " | Inter-branch, via Liability Transfer", undoOps, txnIds);
}
}
return txnIds.isEmpty() ? null : String.join(",", txnIds);
}
private void postBranchEntry(final List<GlEntry> debits, final List<GlEntry> credits, final Long officeId, final String currencyCode,
final String txnDateStr, final String comment, final List<Runnable> undoOps, final List<String> txnIds) {
final String glTxnId = postCombinedJournalEntry(debits, credits, officeId, currencyCode, txnDateStr, comment);
if (glTxnId == null) {
return;
}
linkGlEntriesToSavingsAccounts(glTxnId, debits, 2);
linkGlEntriesToSavingsAccounts(glTxnId, credits, 1);
txnIds.add(glTxnId);
undoOps.add(() -> reverseLedgerEntry(glTxnId));
}
/** Reverses a ledger entry this request posted, when a later step of the same transfer fails. */
private void reverseLedgerEntry(final String glTxnId) {
final CommandWrapper cmd = new CommandWrapperBuilder().reverseJournalEntry(glTxnId)
.withJson("{\"comments\":\"Bank transfer rolled back\"}").build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
}
/** Builds the GL entry comments string listing all affected account numbers. */
private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
final String description) {
@@ -351,8 +351,8 @@ public class ClerkClosureRequestApiResource {
@QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) {
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
final StringBuilder where = new StringBuilder("WHERE status = ?");
final List<Object> baseParams = new ArrayList<>(List.of(status));
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
baseParams.add(Date.valueOf(fromDate));
@@ -1071,28 +1071,7 @@ public class ClerkClosureRequestApiResource {
}
}
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
private void assertMakerInCallersBranch(final Map<?, ?> record) {
final Object makerId = record.get("clerk_id");
final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
if (visible == null || visible == 0) {
throw new NotFoundException("Request not found: " + record.get("id"));
}
}
private Map<?, ?> loadRecord(final Long id) {
final Map<?, ?> record = loadRecordUnscoped(id);
assertMakerInCallersBranch(record);
return record;
}
private Map<?, ?> loadRecordUnscoped(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, savings_account_id, account_no, client_id, client_name, closure_date,
payment_type_id, payment_type_name, description, tok_no, status,
@@ -265,9 +265,7 @@ public class ClerkShareWithdrawalRequestApiResource {
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) {
return queryList("WHERE status = ? AND " + MAKER_IN_HIERARCHY,
new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%")), fromDate, toDate, search,
offset, limit);
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit);
}
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
@@ -1443,29 +1441,7 @@ public class ClerkShareWithdrawalRequestApiResource {
}
}
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
private void assertMakerInCallersBranch(final Map<?, ?> record) {
final Object makerId = record.get("clerk_id");
final Integer visible = makerId == null ? 0
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
if (visible == null || visible == 0) {
throw new NotFoundException("Request not found: " + record.get("id"));
}
}
private Map<?, ?> loadRecord(final Long id) {
final Map<?, ?> record = loadRecordUnscoped(id);
assertMakerInCallersBranch(record);
return record;
}
private Map<?, ?> loadRecordUnscoped(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
if (rows.isEmpty()) {
@@ -48,7 +48,6 @@ 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;
@@ -57,7 +56,6 @@ 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;
@@ -86,7 +84,6 @@ 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 ─────────
@@ -113,28 +110,13 @@ 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 (isGl) {
validateGlWithdrawalAccounts(glAccountId, contraGlId);
} else if (savingsAccountId == null) {
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");
}
@@ -142,9 +124,7 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(400, "Token number (TOK. NO.) is required");
}
validateTokenUniqueToday(tokNo, null);
if (!isGl) {
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
}
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -152,9 +132,8 @@ 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,
gl_account_id, contra_gl_id, office_id, currency_code)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -163,9 +142,7 @@ public class ClerkWithdrawalRequestApiResource {
txnDate != null ? Date.valueOf(txnDate) : null,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
denominationData, activityLog, Timestamp.from(Instant.now()),
isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null,
isGl ? (currencyCode != null ? currencyCode : "INR") : null
denominationData, activityLog, Timestamp.from(Instant.now())
);
// Globally unique voucher number from the central m_voucher register
@@ -188,8 +165,8 @@ public class ClerkWithdrawalRequestApiResource {
@QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) {
final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
final StringBuilder where = new StringBuilder("WHERE status = ?");
final List<Object> baseParams = new ArrayList<>(List.of(status));
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
baseParams.add(Date.valueOf(fromDate));
@@ -221,8 +198,7 @@ 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,"
+ " gl_account_id, contra_gl_id, office_id, currency_code"
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
+ " FROM m_clerk_withdrawal_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -276,8 +252,7 @@ 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,
gl_account_id, contra_gl_id, office_id, currency_code
cashier_id, disbursed_by, disbursed_at, voucher_no
FROM m_clerk_withdrawal_request
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray()
@@ -301,14 +276,12 @@ 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,
gl_account_id, contra_gl_id, office_id, currency_code
cashier_id, disbursed_by, disbursed_at, voucher_no
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
throw new NotFoundException("Withdrawal request not found: " + id);
}
assertMakerInCallersBranch(rows.get(0));
return gson.toJson(rows.get(0));
}
@@ -322,8 +295,7 @@ 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,
gl_account_id, contra_gl_id, office_id, currency_code
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
FROM m_clerk_withdrawal_request
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
ORDER BY submitted_at DESC LIMIT 1
@@ -483,16 +455,13 @@ 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.
if (!isGlRequest) {
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
}
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");
@@ -500,11 +469,7 @@ public class ClerkWithdrawalRequestApiResource {
final String description = (String) record.get("description");
String txnId = 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) {
if (savingsAccountId != null) {
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
@@ -512,18 +477,14 @@ public class ClerkWithdrawalRequestApiResource {
}
// Link voucher to the created savings txn and stamp voucher_no on it
if (txnId != null && !isGlRequest) {
if (txnId != null) {
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.
if (isGlRequest) {
addGlWithdrawalVoucherLines(record, amount);
} else {
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
}
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
@@ -728,8 +689,8 @@ public class ClerkWithdrawalRequestApiResource {
if (denomMap == null || denomMap.size() == 0) return;
final Object txnIdObj = record.get("txn_id");
final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null;
if (txnId == null) return; // no txn linked — cannot record denomination
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
if (txnId == null) return; // no savings txn linked — cannot record denomination
final Object saObj = record.get("savings_account_id");
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
@@ -832,147 +793,14 @@ 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());
}
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
private void assertMakerInCallersBranch(final Map<?, ?> record) {
final Object makerId = record.get("clerk_id");
final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
if (visible == null || visible == 0) {
throw new NotFoundException("Request not found: " + record.get("id"));
}
}
private Map<?, ?> loadRecord(final Long id) {
final Map<?, ?> record = loadRecordUnscoped(id);
assertMakerInCallersBranch(record);
return record;
}
private Map<?, ?> loadRecordUnscoped(final Long id) {
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, cheque_no, bank_name, tok_no,
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,
gl_account_id, contra_gl_id, office_id, currency_code
cashier_id, disbursed_by, disbursed_at, voucher_no
FROM m_clerk_withdrawal_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -1,199 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.ClientIdentityService;
import org.apache.fineract.portfolio.client.service.ClientIdentityService.IdentityMatch;
import org.apache.fineract.portfolio.savings.security.AccountBranchService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* "Is this person already a customer of the bank?", asked from the General step of client creation before anything is
* saved. See {@link ClientIdentityService} for the rules.
*
* <p>
* It is a plain POST resource rather than a command, so the request body is not written to the command-source audit
* table. The Aadhaar arrives only as its SHA-256; the 12-digit number never reaches the server.
*/
@Path("/v1/clientidentity")
@Component
@Tag(name = "Client Identity", description = "Cross-branch duplicate-customer lookup by PAN, Aadhaar fingerprint or GSTIN.")
@RequiredArgsConstructor
public class ClientIdentityLookupApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClientIdentityLookupApiResource.class);
private static final Gson GSON = new Gson();
private final PlatformSecurityContext context;
private final ClientIdentityService clientIdentityService;
private final FromJsonHelper fromJsonHelper;
private final AccountBranchService accountBranchService;
/**
* Introduces a customer of another branch to the caller's branch, so the branch can open an account for them
* there. The customer stays at the home branch; this records who introduced them to which branch and when.
*/
@POST
@Path("link")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Link an existing customer of another branch to the caller's branch",
description = "Body: { clientId }. Idempotent. Lets the caller's branch open an account for the customer.")
public String link(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "CREATE_SAVINGSACCOUNT")) {
throw new NoAuthorizationException("The user doesn't have enough permissions to link a customer to a branch.");
}
final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}"));
final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
if (!body.has("clientId") || body.get("clientId").isJsonNull()) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("error.msg.clientidentity.clientId.required",
"clientId is required.", "clientId")));
}
final Long clientId = body.get("clientId").getAsLong();
final boolean newlyLinked = this.accountBranchService.linkClientToCallerBranch(clientId);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("clientId", clientId);
response.put("linked", newlyLinked);
response.put("officeId", this.context.authenticatedUser().getOffice().getId());
response.put("officeName", this.context.authenticatedUser().getOffice().getName());
return GSON.toJson(response);
}
@POST
@Path("lookup")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Look up an existing customer by PAN, Aadhaar hash or GSTIN, and check phone/email clashes",
description = "Body: { panNumber?, aadhaarHash?, gstinNumber?, mobileNo?, emailAddress?, excludeClientId? }. "
+ "Any ONE of PAN / Aadhaar hash / GSTIN matching an existing client means the same customer. A phone or "
+ "email clash is reported separately under contactConflicts and is never the same customer.")
public String lookup(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "UPDATE_CLIENT")) {
throw new NoAuthorizationException("The user doesn't have enough permissions to look up customers.");
}
final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}"));
final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
final String pan = text(body, "panNumber");
final String aadhaarHash = text(body, "aadhaarHash");
final String gstin = text(body, "gstinNumber");
final String mobileNo = text(body, "mobileNo");
final String emailAddress = text(body, "emailAddress");
final Long excludeClientId = body.has("excludeClientId") && !body.get("excludeClientId").isJsonNull()
? body.get("excludeClientId").getAsLong() : null;
ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHash);
final List<IdentityMatch> matches = this.clientIdentityService.findMatches(pan, aadhaarHash, gstin, excludeClientId);
final Map<String, Boolean> contactConflicts = this.clientIdentityService.contactConflicts(mobileNo, emailAddress,
excludeClientId);
// Who looked up what, never the values: a lookup that finds a customer at another branch is exactly what
// someone browsing for customers would do, so there has to be a trail.
LOG.info("client identity lookup: user={} fields={} matchedClientIds={}", this.context.authenticatedUser().getUsername(),
suppliedFields(pan, aadhaarHash, gstin, mobileNo, emailAddress), matches.stream().map(IdentityMatch::clientId).toList());
final Set<Long> distinctClients = new LinkedHashSet<>();
matches.forEach(m -> distinctClients.add(m.clientId()));
final Map<String, Object> response = new LinkedHashMap<>();
response.put("matched", !matches.isEmpty());
// PAN points at one customer and Aadhaar (or GSTIN) at another: bad data or fraud, not a customer to reuse.
response.put("conflict", distinctClients.size() > 1);
response.put("matches", toJsonList(matches));
response.put("contactConflicts", contactConflicts);
return GSON.toJson(response);
}
private static String text(final JsonObject body, final String name) {
final JsonElement element = body.get(name);
return element == null || element.isJsonNull() ? null : StringUtils.trimToNull(element.getAsString());
}
private static List<String> suppliedFields(final String pan, final String aadhaarHash, final String gstin, final String mobileNo,
final String emailAddress) {
final List<String> fields = new ArrayList<>();
if (pan != null) {
fields.add("pan");
}
if (aadhaarHash != null) {
fields.add("aadhaar");
}
if (gstin != null) {
fields.add("gstin");
}
if (mobileNo != null) {
fields.add("mobile");
}
if (emailAddress != null) {
fields.add("email");
}
return fields;
}
private static List<Map<String, Object>> toJsonList(final List<IdentityMatch> matches) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final IdentityMatch m : matches) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("clientId", m.clientId());
row.put("displayName", m.displayName());
row.put("cifNumber", m.cifNumber());
row.put("officeId", m.officeId());
row.put("officeName", m.officeName());
row.put("legalFormEnum", m.legalFormEnum());
row.put("kycStatus", m.kycStatus());
// True when the caller's branch can open this client; false when it sits in another branch's hierarchy.
row.put("visibleToCaller", m.visibleToCaller());
row.put("panMasked", m.panMasked());
row.put("aadhaarMasked", m.aadhaarMasked());
row.put("gstinMasked", m.gstinMasked());
row.put("matchedBy", new ArrayList<>(m.matchedBy()));
out.add(row);
}
return out;
}
}
@@ -28,7 +28,6 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
@@ -37,7 +36,6 @@ import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSer
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycContactLogData;
import org.apache.fineract.portfolio.client.service.KycContactLogService;
import org.apache.fineract.portfolio.client.service.KycNoticeComposer;
import org.springframework.stereotype.Component;
/**
@@ -67,7 +65,6 @@ public class ClientKycContactLogApiResource {
private final PlatformSecurityContext context;
private final KycContactLogService kycContactLogService;
private final KycNoticeComposer noticeComposer;
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
@POST
@@ -82,31 +79,11 @@ public class ClientKycContactLogApiResource {
final JsonObject payload = parse(apiRequestBodyAsJson);
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"), stringOrNull(payload, "noticeType"));
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"));
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("preview")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Preview the notice a customer would receive",
description = "The subject and text for the stage the customer is at today. type is KYC or DORMANCY. "
+ "Empty subject and body when the customer is at no stage of that kind.")
public String previewNotice(@PathParam("clientId") final Long clientId, @QueryParam("type") final String type) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final KycNoticeComposer.KycNotice notice = this.noticeComposer.compose(clientId, type);
final JsonObject json = new JsonObject();
if (notice != null) {
final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(notice);
json.addProperty("subject", delivery.subject());
json.addProperty("body", delivery.body());
}
return json.toString();
}
@GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@@ -89,24 +89,4 @@ public class ClientKycExceptionsApiResource {
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
return this.toApiJsonSerializer.serialize(lapsed);
}
/**
* Customers whose periodic re-KYC falls due within the next {@code days} days (default 30).
*
* <p>
* The counterpart of {@code lapsed}: nothing has stopped working for these customers yet, which is
* why they are worth reaching now.
*/
@GET
@Path("expiring")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve KYC records expiring soon", description = "Returns clients whose periodic re-KYC falls due within the next N days (default 30), soonest first.")
public String retrieveExpiringKyc(@QueryParam("days") final Integer days) {
this.context.authenticatedUser();
final int window = days == null ? 30 : days;
final List<ClientKycData> expiring = this.readPlatformService.retrieveExpiringKyc(window);
LOG.info("retrieveExpiringKyc: fetched {} KYC record(s) expiring within {} day(s)", expiring.size(), window);
return this.toApiJsonSerializer.serialize(expiring);
}
}
@@ -1,205 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Every customer notice on record, across all customers, for audit.
*
* <pre>
* GET /v1/kyc/contact-audit/days one line per day: how many customers and notices, and how they fared
* GET /v1/kyc/contact-audit the notices themselves, a page at a time: {"total": n, "rows": [...]}
* </pre>
*
* <p>
* Both take the same optional filters: {@code from} and {@code to} (yyyy-MM-dd, both inclusive),
* {@code channel}, {@code status} and {@code search} (customer name, account number or reference). The
* per-customer contact log answers "what was this customer told"; this answers "what did the bank send,
* to whom, and did it arrive" — the question an inspection asks.
*
* <p>
* Built for a bank with a very large number of customers: the day view is computed in the database and
* is a few hundred rows at most, and the notices of one day are fetched a page at a time, so nothing
* here ever loads the whole log.
*/
@Path("/v1/kyc/contact-audit")
@Component
@Tag(name = "Client KYC Contact Audit", description = "All customer notices on record.")
@RequiredArgsConstructor
public class KycContactAuditApiResource {
private static final int MAX_PAGE = 500;
private static final int MAX_DAYS = 400;
private static final String FROM_CLAUSE = " FROM m_client_kyc_contact_log l JOIN m_client c ON c.id = l.client_id WHERE 1 = 1";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@GET
@Path("days")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.")
public String days(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
@QueryParam("search") final String search) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Object> args = new ArrayList<>();
final String where = filters(from, to, channel, status, search, args);
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
+ "SUM(CASE WHEN l.delivery_status = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, "
+ "SUM(CASE WHEN l.delivery_status = 'PROVIDER_NOT_CONFIGURED' THEN 1 ELSE 0 END) AS not_configured, "
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded" + FROM_CLAUSE + where
+ " GROUP BY CAST(l.contacted_at AS DATE) ORDER BY d DESC LIMIT " + MAX_DAYS;
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final Map<String, Object> item = new LinkedHashMap<>();
item.put("date", String.valueOf(r.get("d")));
item.put("notices", number(r.get("notices")));
item.put("customers", number(r.get("customers")));
item.put("delivered", number(r.get("delivered")));
item.put("failed", number(r.get("failed")));
item.put("noContact", number(r.get("no_contact")));
item.put("notConfigured", number(r.get("not_configured")));
item.put("recorded", number(r.get("recorded")));
out.add(item);
}
return this.gson.toJson(out);
}
@GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.")
public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
@QueryParam("search") final String search, @QueryParam("offset") final Integer offset,
@QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Object> args = new ArrayList<>();
final String where = filters(from, to, channel, status, search, args);
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray());
final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE);
final int skip = Math.max(offset == null ? 0 : offset, 0);
final String sql = "SELECT l.id, l.client_id, c.display_name, c.account_no, l.channel, l.notes, l.officer_name, "
+ "l.delivery_status, l.destination_masked, l.provider, l.provider_reference, l.failure_reason, l.message_body, "
+ "l.contacted_at" + FROM_CLAUSE + where + " ORDER BY l.contacted_at DESC, l.id DESC LIMIT " + rows + " OFFSET " + skip;
final List<Map<String, Object>> list = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final Map<String, Object> item = new LinkedHashMap<>();
item.put("id", r.get("id"));
item.put("clientId", r.get("client_id"));
item.put("clientName", r.get("display_name"));
item.put("accountNo", r.get("account_no"));
item.put("channel", r.get("channel"));
item.put("notes", r.get("notes"));
item.put("officerName", r.get("officer_name"));
item.put("deliveryStatus", r.get("delivery_status"));
item.put("destinationMasked", r.get("destination_masked"));
item.put("provider", r.get("provider"));
item.put("providerReference", r.get("provider_reference"));
item.put("failureReason", r.get("failure_reason"));
item.put("messageBody", r.get("message_body"));
final Object at = r.get("contacted_at");
item.put("contactedAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
list.add(item);
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("total", total == null ? 0 : total);
out.put("rows", list);
return this.gson.toJson(out);
}
/** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */
private String filters(final String from, final String to, final String channel, final String status, final String search,
final List<Object> args) {
final StringBuilder where = new StringBuilder();
if (StringUtils.isNotBlank(from)) {
where.append(" AND l.contacted_at >= ?");
args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay()));
}
if (StringUtils.isNotBlank(to)) {
where.append(" AND l.contacted_at < ?");
args.add(Timestamp.valueOf(parse(to, "to").plusDays(1).atStartOfDay()));
}
if (StringUtils.isNotBlank(channel)) {
where.append(" AND l.channel = ?");
args.add(channel.trim().toUpperCase());
}
if (StringUtils.isNotBlank(status)) {
where.append(" AND l.delivery_status = ?");
args.add(status.trim().toUpperCase());
}
if (StringUtils.isNotBlank(search)) {
final String like = "%" + search.trim().toLowerCase() + "%";
where.append(" AND (LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(l.provider_reference) LIKE ?)");
args.add(like);
args.add(like);
args.add(like);
}
return where.toString();
}
private static long number(final Object value) {
return value == null ? 0 : ((Number) value).longValue();
}
private LocalDate parse(final String value, final String parameter) {
try {
return LocalDate.parse(value.trim());
} catch (final DateTimeParseException e) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"validation.msg.kyc.contact.audit." + parameter + ".invalid", parameter + " must look like 2028-02-17.", parameter)));
}
}
}
@@ -1,156 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService;
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.EnqueueResult;
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.QueueItem;
import org.springframework.stereotype.Component;
/**
* Notices queued to be sent in the background.
*
* <pre>
* POST /v1/kyc/notice-queue body: {"items":[{"clientId":1,"noticeType":"KYC","channel":"SMS",
* "accountNo":"..","stageLabel":".."}],
* "notes":"..","officerName":"..","sendAt":"2028-02-17T10:00"}
* GET /v1/kyc/notice-queue?status=PENDING newest first
* POST /v1/kyc/notice-queue/{id}?command=cancel | retry
* </pre>
*
* <p>Authorisation matches the contact log: sending a notice to a customer is a maintenance action on
* the customer's record.
*/
@Path("/v1/kyc/notice-queue")
@Component
@Tag(name = "Client KYC Notice Queue", description = "Queue customer notices to be sent in the background.")
@RequiredArgsConstructor
public class KycNoticeQueueApiResource {
private final PlatformSecurityContext context;
private final KycNoticeQueueService queueService;
private final Gson gson = new Gson();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Queue notices for background sending")
public String enqueue(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final JsonObject payload = parse(apiRequestBodyAsJson);
final List<QueueItem> items = new ArrayList<>();
final JsonElement raw = payload.get("items");
if (raw != null && raw.isJsonArray()) {
final JsonArray array = raw.getAsJsonArray();
for (final JsonElement element : array) {
if (!element.isJsonObject()) {
continue;
}
final JsonObject o = element.getAsJsonObject();
final String clientId = string(o, "clientId");
items.add(new QueueItem(clientId == null ? null : Long.valueOf(clientId), string(o, "noticeType"), string(o, "channel"),
string(o, "accountNo"), string(o, "stageLabel")));
}
}
LocalDateTime sendAt = null;
final String sendAtText = string(payload, "sendAt");
if (sendAtText != null && !sendAtText.isBlank()) {
try {
sendAt = LocalDateTime.parse(sendAtText);
} catch (final DateTimeParseException e) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"validation.msg.kyc.notice.queue.sendAt.invalid", "sendAt must look like 2028-02-17T10:00.", "sendAt")));
}
}
final EnqueueResult result = this.queueService.enqueue(items, string(payload, "notes"), string(payload, "officerName"), sendAt);
final JsonObject out = new JsonObject();
out.addProperty("queued", result.queued());
out.addProperty("skipped", result.skipped());
return out.toString();
}
@GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List queued notices", description = "Newest first. status narrows to PENDING, SENT, FAILED or CANCELLED.")
public String list(@QueryParam("status") final String status, @QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
return this.gson.toJson(this.queueService.list(status, limit == null ? 200 : limit));
}
@POST
@Path("{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Cancel or retry a queued notice", description = "command=cancel withdraws a notice still waiting; "
+ "command=retry puts a failed one back in line.")
public String act(@PathParam("id") final Long id, @QueryParam("command") final String command) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
if ("cancel".equalsIgnoreCase(command)) {
this.queueService.cancel(id);
} else if ("retry".equalsIgnoreCase(command)) {
this.queueService.retry(id);
} else {
throw new PlatformApiDataValidationException(List.of(ApiParameterError
.parameterError("validation.msg.kyc.notice.queue.command.invalid", "command must be cancel or retry.", "command")));
}
final JsonObject out = new JsonObject();
out.addProperty("id", id);
return out.toString();
}
private JsonObject parse(final String json) {
if (json == null || json.isBlank()) {
return new JsonObject();
}
final JsonElement parsed = JsonParser.parseString(json);
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
}
private String string(final JsonObject o, final String member) {
final JsonElement element = o.get(member);
return element == null || element.isJsonNull() ? null : element.getAsString();
}
}
@@ -24,7 +24,6 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
@@ -70,14 +69,4 @@ public class KycRiskRulesApiResource {
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
return this.toApiJsonSerializer.serialize(rule);
}
@POST
@Path("{ruleId}/mark-normal")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Mark a rule as normal", description = "Ends the trial of a newly added rule. It stays in the catalog as a normal rule.")
public String markNormal(@PathParam("ruleId") final Long ruleId) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
return this.toApiJsonSerializer.serialize(this.writePlatformService.markNormal(ruleId));
}
}
@@ -33,8 +33,6 @@ public final class ClientKycApiConstants {
public static final String riskCategoryParam = "riskCategory";
public static final String panNumberParam = "panNumber";
public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked";
/** SHA-256 of the full Aadhaar, computed client-side. The 12-digit number itself is never sent on create/update. */
public static final String aadhaarHashParam = "aadhaarHash";
public static final String gstinNumberParam = "gstinNumber";
public static final String remarksParam = "remarks";
public static final String ckycNumberParam = "ckycNumber";
@@ -120,7 +118,7 @@ public final class ClientKycApiConstants {
// ── Supported parameters for initiate/update ──────────────────────────────
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam,
kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam,
gstinNumberParam, remarksParam, "locale", "dateFormat",
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
@@ -138,7 +136,7 @@ public final class ClientKycApiConstants {
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam, gstinNumberParam,
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
riskCategoryParam, remarksParam, "locale", "dateFormat",
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
@@ -64,12 +64,6 @@ public final class KycRiskRuleData implements Serializable {
* used in matching; a null maximum means the rule is genuinely open-ended. */
private final BigDecimal txnMinLimitAmount;
private final BigDecimal txnMaxLimitAmount;
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
private final boolean ccRelevant;
/** True while a newly authored rule is on trial; it is listed first until an officer marks it normal. */
private final boolean onTrial;
/** When the trial began (ISO date), or null for a rule that is not on trial. */
private final String trialStartedOn;
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName,
@@ -82,8 +76,7 @@ public final class KycRiskRuleData implements Serializable {
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant,
final boolean onTrial, final String trialStartedOn) {
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
this.id = id;
this.ruleName = ruleName;
this.ruleKind = ruleKind;
@@ -113,9 +106,6 @@ public final class KycRiskRuleData implements Serializable {
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
this.txnMinLimitAmount = txnMinLimitAmount;
this.txnMaxLimitAmount = txnMaxLimitAmount;
this.ccRelevant = ccRelevant;
this.onTrial = onTrial;
this.trialStartedOn = trialStartedOn;
}
public Long getId() {
@@ -233,16 +223,4 @@ public final class KycRiskRuleData implements Serializable {
public BigDecimal getTxnMaxLimitAmount() {
return txnMaxLimitAmount;
}
public boolean isCcRelevant() {
return ccRelevant;
}
public boolean isOnTrial() {
return onTrial;
}
public String getTrialStartedOn() {
return trialStartedOn;
}
}
@@ -104,26 +104,15 @@ public class KycRiskRuleRequest {
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
private String txnRuleType;
@Schema(description = "SAVINGS | LOAN | TRANSFER | CURRENT | ALL. Defaults to ALL. CURRENT is a current / Cash Credit account.", example = "ALL")
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
private String txnAppliesTo;
@Schema(description = "CREDIT (money in) or DEBIT (money out). Omit for either direction.", example = "CREDIT")
private String txnDirection;
@Schema(description = "True when the rule is about cash only. Defaults to false.")
private Boolean txnCashOnly;
@Schema(description = "True when the scenario watches Cash Credit accounts, so the Rules Master CC chip lists it.")
private Boolean ccRelevant;
/**
* True when the officer has described a live-transaction rule rather than a pure KYC profile
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
*/
public boolean hasTransactionRule() {
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|| (this.txnWindowDays != null && this.txnWindowDays > 0)
|| (this.txnDirection != null && !this.txnDirection.isBlank()) || Boolean.TRUE.equals(this.txnCashOnly)
|| (this.txnAppliesTo != null && !this.txnAppliesTo.isBlank() && !"ALL".equalsIgnoreCase(this.txnAppliesTo.trim()));
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
}
}
@@ -25,7 +25,6 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -40,7 +39,6 @@ public class ClientRepositoryWrapper {
private final ClientRepository repository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@Transactional(readOnly = true)
public Client findOneWithNotFoundDetection(final Long id) {
@@ -96,33 +94,12 @@ public class ClientRepositoryWrapper {
public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) {
Client client = this.repository.fetchByClientIdAndHierarchy(clientId, hierarchySearchString, hierarchySearchString);
// A customer of another branch who has been linked to the caller's branch can be seen here, so the branch can
// open an account for them. Reading only: the write paths do not go through this method.
if (client == null && isLinkedToBranchUnder(clientId, hierarchySearchString)) {
client = this.repository.findById(clientId).orElse(null);
}
if (client == null) {
throw new ClientNotFoundException(clientId.toString(), "client.id");
}
return client;
}
/**
* True when the client has been linked to an office at or below the given hierarchy (a LIKE pattern ending in %), or
* holds an account there. A link is what lets a branch open the customer's page to start an account; the client list
* is stricter and shows the customer only once an account exists.
*/
public boolean isLinkedToBranchUnder(final Long clientId, final String hierarchySearchString) {
final Integer count = this.jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM ("
+ "SELECT l.client_id FROM m_client_branch_link l JOIN m_office lo ON lo.id = l.office_id WHERE l.client_id = ? AND lo.hierarchy LIKE ? "
+ "UNION ALL SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id WHERE sa.client_id = ? AND ao.hierarchy LIKE ? "
+ "UNION ALL SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id WHERE ca.client_id = ? AND co.hierarchy LIKE ?"
+ ") t",
Integer.class, clientId, hierarchySearchString, clientId, hierarchySearchString, clientId, hierarchySearchString);
return count != null && count > 0;
}
public Long findIdByExternalId(final ExternalId externalId) {
return this.repository.findIdByExternalId(externalId);
}
@@ -1,52 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.jobs.processkycnoticequeue;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ProcessKycNoticeQueueConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step processKycNoticeQueueStep(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) {
return new StepBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository)
.tasklet(processKycNoticeQueueTasklet, transactionManager).build();
}
@Bean
public Job processKycNoticeQueueJob(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) {
return new JobBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository)
.start(processKycNoticeQueueStep(processKycNoticeQueueTasklet)).incrementer(new RunIdIncrementer()).build();
}
}
@@ -1,52 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.jobs.sendkycreminders;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class SendKycRemindersConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step sendKycRemindersStep(SendKycRemindersTasklet sendKycRemindersTasklet) {
return new StepBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).tasklet(sendKycRemindersTasklet, transactionManager)
.build();
}
@Bean
public Job sendKycRemindersJob(SendKycRemindersTasklet sendKycRemindersTasklet) {
return new JobBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).start(sendKycRemindersStep(sendKycRemindersTasklet))
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -29,7 +29,6 @@ import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -276,7 +275,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
// CBS extended fields
clientFamilyMembers.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
clientFamilyMembers.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber")));
clientFamilyMembers.setPanNumber(command.stringValueOfParameterNamed("panNumber"));
clientFamilyMembers.setNationality(command.stringValueOfParameterNamed("nationality"));
final Boolean isAlive = command.booleanObjectValueOfParameterNamed("isAlive");
clientFamilyMembers.setIsAlive(isAlive != null ? isAlive : true);
@@ -427,7 +426,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
familyMember.setAadhaarNumber(member.get("aadhaarNumber").getAsString());
}
if (member.get("panNumber") != null && !member.get("panNumber").isJsonNull()) {
familyMember.setPanNumber(StringUtils.upperCase(member.get("panNumber").getAsString()));
familyMember.setPanNumber(member.get("panNumber").getAsString());
}
if (member.get("nationality") != null && !member.get("nationality").isJsonNull()) {
familyMember.setNationality(member.get("nationality").getAsString());
@@ -586,7 +585,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
clientFamilyMember.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
}
if (command.parameterExists("panNumber")) {
clientFamilyMember.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber")));
clientFamilyMember.setPanNumber(command.stringValueOfParameterNamed("panNumber"));
}
if (command.parameterExists("nationality")) {
clientFamilyMember.setNationality(command.stringValueOfParameterNamed("nationality"));
@@ -1,207 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.AadhaarHash;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Answers "is this person already a customer of the bank?" by identity number, across every branch.
*
* <p>
* PAN, the Aadhaar fingerprint and GSTIN identify a person or an entity, so any ONE of them matching means the same
* customer. Phone and email are only contact details - a match there is a different customer using a number or
* address that is already taken, never the same customer.
*
* <p>
* The lookup deliberately ignores the caller's office hierarchy: the whole point is that a branch must learn that the
* customer is already on the books at another branch. What it hands back is kept to the minimum needed to act on that
* (name, CIF, branch, masked identifiers) and every lookup is logged.
*/
@Service
@RequiredArgsConstructor
public class ClientIdentityService {
/** What a caller is told about an existing customer. Identifiers are masked; nothing here is editable. */
public record IdentityMatch(Long clientId, String displayName, String cifNumber, Long officeId, String officeName,
Integer legalFormEnum, Integer kycStatus, boolean visibleToCaller, String panMasked, String aadhaarMasked,
String gstinMasked, Set<String> matchedBy) {
}
public static final String MATCHED_PAN = "PAN";
public static final String MATCHED_AADHAAR = "AADHAAR";
public static final String MATCHED_GSTIN = "GSTIN";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
/**
* Every active client whose KYC carries any of the given identifiers. A blank identifier is ignored; if all three
* are blank nothing can match. At most one row per client (a client has one KYC row).
*
* @param excludeClientId a client to leave out (the one being edited), or null
*/
public List<IdentityMatch> findMatches(final String panNumber, final String aadhaarHash, final String gstinNumber,
final Long excludeClientId) {
final String pan = normalizeId(panNumber);
final String hash = AadhaarHash.normalizeOrNull(aadhaarHash);
final String gstin = normalizeId(gstinNumber);
final List<String> conditions = new ArrayList<>();
final List<Object> params = new ArrayList<>();
if (pan != null) {
conditions.add("k.pan_number = ?");
params.add(pan);
}
if (hash != null) {
conditions.add("k.aadhaar_hash = ?");
params.add(hash);
}
if (gstin != null) {
conditions.add("k.gstin_number = ?");
params.add(gstin);
}
if (conditions.isEmpty()) {
return List.of();
}
final StringBuilder sql = new StringBuilder("SELECT c.id, c.display_name, c.cif_number, c.office_id, o.name AS office_name, ")
.append("o.hierarchy AS office_hierarchy, c.legal_form_enum, k.kyc_status, k.pan_number, ")
.append("k.aadhaar_number_masked, k.aadhaar_hash, k.gstin_number ")
.append("FROM m_client_kyc k JOIN m_client c ON c.id = k.client_id JOIN m_office o ON o.id = c.office_id ")
.append("WHERE k.is_deleted = false AND (").append(String.join(" OR ", conditions)).append(")");
if (excludeClientId != null) {
sql.append(" AND c.id <> ?");
params.add(excludeClientId);
}
sql.append(" ORDER BY c.id");
final String callerHierarchy = this.context.officeHierarchy();
return this.jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
final Set<String> matchedBy = new LinkedHashSet<>();
if (pan != null && pan.equals(rs.getString("pan_number"))) {
matchedBy.add(MATCHED_PAN);
}
if (hash != null && hash.equals(rs.getString("aadhaar_hash"))) {
matchedBy.add(MATCHED_AADHAAR);
}
if (gstin != null && gstin.equals(rs.getString("gstin_number"))) {
matchedBy.add(MATCHED_GSTIN);
}
final String officeHierarchy = rs.getString("office_hierarchy");
// wasNull() only describes the column read immediately before it, so capture it right here.
final int legalForm = rs.getInt("legal_form_enum");
final boolean legalFormMissing = rs.wasNull();
return new IdentityMatch(rs.getLong("id"), rs.getString("display_name"), rs.getString("cif_number"),
rs.getLong("office_id"), rs.getString("office_name"), legalFormMissing ? null : legalForm,
rs.getInt("kyc_status"), officeHierarchy != null && officeHierarchy.startsWith(callerHierarchy),
maskKeepingEnds(rs.getString("pan_number"), 2, 1), rs.getString("aadhaar_number_masked"),
maskKeepingEnds(rs.getString("gstin_number"), 2, 3), matchedBy);
}, params.toArray());
}
/**
* Whether a mobile number or email address is already used by another client. Contact details are unique per
* client, and a hit here is never "the same customer".
*/
public Map<String, Boolean> contactConflicts(final String mobileNo, final String emailAddress, final Long excludeClientId) {
final Map<String, Boolean> conflicts = new LinkedHashMap<>();
conflicts.put("mobileNo", isTaken("mobile_no", StringUtils.trimToNull(mobileNo), excludeClientId));
conflicts.put("emailAddress", isTaken("email_address", StringUtils.trimToNull(emailAddress), excludeClientId));
return conflicts;
}
/**
* Rejects a KYC save that would put an identifier on a second client. Called with only the identifiers being
* added or changed, so editing a client that already shares an identifier with another (possible in data saved
* before this check existed) is not blocked by that old clash.
*/
public void assertNoDuplicate(final Long clientId, final String panNumber, final String aadhaarHash, final String gstinNumber) {
final List<IdentityMatch> matches = findMatches(panNumber, aadhaarHash, gstinNumber, clientId);
if (matches.isEmpty()) {
return;
}
final IdentityMatch other = matches.get(0);
final String where = " (CIF " + other.cifNumber() + ", branch " + other.officeName() + ").";
if (other.matchedBy().contains(MATCHED_PAN)) {
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.panNumber",
"A client with PAN `" + normalizeId(panNumber) + "` already exists" + where
+ " PAN must be unique across clients.", "panNumber", normalizeId(panNumber));
}
if (other.matchedBy().contains(MATCHED_AADHAAR)) {
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.aadhaar",
"A client with this Aadhaar already exists" + where + " Aadhaar must be unique across clients.",
"aadhaarNumber", other.cifNumber());
}
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.gstin",
"A client with GSTIN `" + normalizeId(gstinNumber) + "` already exists" + where
+ " GSTIN must be unique across clients.", "gstinNumber", normalizeId(gstinNumber));
}
/**
* Checks an Aadhaar fingerprint from a request is well formed, as a normal validation error rather than a 500.
* Blank is allowed (no Aadhaar).
*/
public static void validateAadhaarHashOrThrow(final String rawHash) {
if (StringUtils.isNotBlank(rawHash) && !AadhaarHash.isValid(rawHash.trim().toLowerCase(Locale.ROOT))) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"error.msg.client.kyc.aadhaarHash.invalid", "aadhaarHash must be a 64-character hex SHA-256 digest.",
"aadhaarHash")));
}
}
/** PAN and GSTIN are stored upper-case; compare them that way. Null for blank. */
public static String normalizeId(final String raw) {
final String trimmed = StringUtils.trimToNull(raw);
return trimmed == null ? null : trimmed.toUpperCase(Locale.ROOT);
}
private boolean isTaken(final String column, final String value, final Long excludeClientId) {
if (value == null) {
return false;
}
// column is one of two fixed names above, never caller input.
final String sql = "SELECT COUNT(1) FROM m_client WHERE " + column + " = ?" + (excludeClientId != null ? " AND id <> ?" : "");
final Integer count = excludeClientId != null ? this.jdbcTemplate.queryForObject(sql, Integer.class, value, excludeClientId)
: this.jdbcTemplate.queryForObject(sql, Integer.class, value);
return count != null && count > 0;
}
/** Keeps the first {@code head} and last {@code tail} characters, e.g. PAN ABCDE1234F -> AB*******F. */
private static String maskKeepingEnds(final String value, final int head, final int tail) {
if (value == null || value.length() <= head + tail) {
return value == null ? null : "*".repeat(value.length());
}
return value.substring(0, head) + "*".repeat(value.length() - head - tail) + value.substring(value.length() - tail);
}
}
@@ -45,14 +45,4 @@ public interface ClientKycReadPlatformService {
* included: nothing is owed on it yet.
*/
java.util.List<ClientKycData> retrieveLapsedKyc();
/**
* KYC records whose re-KYC falls due within the next {@code days} days, soonest first.
*
* <p>
* Nothing is owed on these yet, but they are the customers the branch can still reach before the
* restriction ladder starts. A record that has already lapsed is in {@link #retrieveLapsedKyc()}
* instead.
*/
java.util.List<ClientKycData> retrieveExpiringKyc(int days);
}
@@ -173,13 +173,4 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
@Override
public java.util.List<ClientKycData> retrieveExpiringKyc(final int days) {
final java.time.LocalDate today = DateUtils.getBusinessLocalDate();
return this.kycRepository
.findExpiringBetween(today, today.plusDays(Math.max(days, 0)), ClientKycStatus.REJECTED.getValue()).stream()
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
}
@@ -77,7 +77,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
private final SavingsAccountRepository savingsAccountRepository;
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
private final ClientIdentityService clientIdentityService;
/** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */
private static final int CKYC_ZIP_MAX_BYTES = 50 * 1024 * 1024;
@@ -86,8 +85,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
@Override
public CommandProcessingResult initiateKyc(final Long clientId, final JsonCommand command) {
this.context.authenticatedUser();
LOG.info("initiateKyc: clientId={}, requestJson={}", clientId,
command.json().replaceAll("\"aadhaarHash\"\\s*:\\s*\"[0-9a-fA-F]*\"", "\"aadhaarHash\":\"<redacted>\""));
LOG.info("initiateKyc: clientId={}, requestJson={}", clientId, command.json());
final Client clientForValidation = clientRepository.findOneWithNotFoundDetection(clientId);
@@ -110,13 +108,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
.build();
}
// PAN, Aadhaar and GSTIN identify one customer for the whole bank. The create-client request used to be the
// only place this was checked, but the wizard saves the KYC through this call, so a duplicate got through.
final String aadhaarHashInRequest = command.stringValueOfParameterNamed(ClientKycApiConstants.aadhaarHashParam);
ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHashInRequest);
this.clientIdentityService.assertNoDuplicate(clientId, command.stringValueOfParameterNamed(ClientKycApiConstants.panNumberParam),
aadhaarHashInRequest, command.stringValueOfParameterNamed(ClientKycApiConstants.gstinNumberParam));
try {
final Client client = clientForValidation;
final ClientKyc kyc = ClientKyc.initiateKyc(client, command);
@@ -154,18 +145,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final ClientKycStatus currentStatus = kyc.getKycStatus();
// Check only identifiers this request adds or changes. A client that already shares one with another client
// (saved before this check existed) must still be editable.
final String hashInRequest = command.stringValueOfParameterNamed(ClientKycApiConstants.aadhaarHashParam);
ClientIdentityService.validateAadhaarHashOrThrow(hashInRequest);
final String newPan = command.isChangeInStringParameterNamed(ClientKycApiConstants.panNumberParam, kyc.getPanNumber())
? command.stringValueOfParameterNamed(ClientKycApiConstants.panNumberParam) : null;
final String newGstin = command.isChangeInStringParameterNamed(ClientKycApiConstants.gstinNumberParam, kyc.getGstinNumber())
? command.stringValueOfParameterNamed(ClientKycApiConstants.gstinNumberParam) : null;
final String newHash = StringUtils.isNotBlank(hashInRequest) && !hashInRequest.trim().equalsIgnoreCase(kyc.getAadhaarHash())
? hashInRequest : null;
this.clientIdentityService.assertNoDuplicate(clientId, newPan, newHash, newGstin);
final Map<String, Object> changes = kyc.update(command);
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
@@ -109,18 +109,11 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
// this.context.validateAccessRights(searchParameters.getHierarchy());
// underHierarchySearchString = searchParameters.getHierarchy() + "%";
// }
List<Object> paramList = new ArrayList<>(Arrays.asList(underHierarchySearchString, underHierarchySearchString,
underHierarchySearchString, underHierarchySearchString));
List<Object> paramList = new ArrayList<>(Arrays.asList(underHierarchySearchString, underHierarchySearchString));
final StringBuilder sqlBuilder = new StringBuilder(200);
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
sqlBuilder.append(this.clientToDataMapper.schema());
// The last two alternatives: a customer of another branch who holds an account at this branch. Being linked to a
// branch is not enough to be listed there; the customer appears once an account is actually opened.
sqlBuilder.append(" where (o.hierarchy like ? or transferToOffice.hierarchy like ?"
+ " or c.id in (select sa.client_id from m_savings_account sa join m_office ao on ao.id = sa.office_id"
+ " where sa.client_id is not null and ao.hierarchy like ?)"
+ " or c.id in (select ca.client_id from m_current_account ca join m_office co on co.id = ca.office_id"
+ " where ca.client_id is not null and co.hierarchy like ?)) ");
sqlBuilder.append(" where (o.hierarchy like ? or transferToOffice.hierarchy like ?) ");
if (searchParameters != null) {
if (searchParameters.getIsSelfUser()) {
@@ -274,9 +274,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
aadhaar = this.fromApiJsonHelper.extractStringNamed(
org.apache.fineract.portfolio.client.data.ClientKycApiConstants.aadhaarNumberParam, kyc);
}
// "XXXXXXXX0000" is the placeholder the wizard sends for entities and for clients with no Aadhaar. It is not a
// number, so every such client would "match" every other one.
if (StringUtils.isNotBlank(aadhaar) && !"XXXXXXXX0000".equals(aadhaar)) {
if (StringUtils.isNotBlank(aadhaar)) {
final String masked = aadhaar.length() == 12 ? "XXXXXXXX" + aadhaar.substring(8) : aadhaar;
final boolean clash = excludeClientId == null
? clientKycRepository.existsByAadhaarMasked(masked)
@@ -42,37 +42,6 @@ public interface KycContactLogService {
*/
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
/**
* As {@link #logContact(Long, String, String, String)}, but a sending channel carries the notice for the stage
* the customer is at today ({@code noticeType} {@code KYC} or {@code DORMANCY}) instead of the generic
* "KYC pending" text. The stage is read from the customer's own record, not from the caller.
*
* @param noticeType {@code KYC}, {@code DORMANCY}, or blank for the generic notice
*/
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName, String noticeType);
/**
* Sends a notice whose wording the caller supplies, and logs it exactly as {@link #logContact} would.
*
* <p>Used by the nightly reminder job, which knows which stage the customer is at and what happens
* next, and says so in the message — the generic "KYC pending" text does not. Only the sending
* channels (SMS, Email, WhatsApp) are accepted. The bank's name is appended to {@code body}.
*
* @param notes recorded against the log; the caller starts it with a marker so a reminder is not
* repeated
* @param subject email subject (without the bank name)
* @param body plain-text message
*/
KycContactLogData sendNotice(Long clientId, String rawChannel, String notes, String officerName, String subject, String body);
/**
* Sends the notice for the stage the customer is at today and logs it, without needing a logged-in user —
* for the queue job, which sends what an officer queued earlier.
*
* @param noticeType {@code KYC} or {@code DORMANCY}
*/
KycContactLogData sendStageNotice(Long clientId, String rawChannel, String notes, String officerName, String noticeType);
/** All contact attempts for one customer, oldest first. */
List<KycContactLogData> retrieveForClient(Long clientId);
}
@@ -42,7 +42,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@@ -65,30 +64,21 @@ public class KycContactLogServiceImpl implements KycContactLogService {
private final CustomerNotificationDispatcher dispatcher;
private final EmailTemplateService emailTemplateService;
private final PlatformSecurityContext context;
private final KycNoticeComposer noticeComposer;
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context,
final KycNoticeComposer noticeComposer) {
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) {
this.clientRepositoryWrapper = clientRepositoryWrapper;
this.kycRepository = kycRepository;
this.contactLogRepository = contactLogRepository;
this.dispatcher = dispatcher;
this.emailTemplateService = emailTemplateService;
this.context = context;
this.noticeComposer = noticeComposer;
}
@Override
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
return logContact(clientId, rawChannel, notes, officerName, null);
}
@Override
@Transactional
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName,
final String noticeType) {
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
if (channel == null) {
throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
@@ -109,22 +99,11 @@ public class KycContactLogServiceImpl implements KycContactLogService {
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
if (channel.isPlatformSent()) {
String subject = "Action Required: KYC Verification Pending - " + this.bankName;
String plainBody = buildPlainBody(client, exceptionReason, channel);
String htmlTitle = "KYC Verification Pending";
if (StringUtils.isNotBlank(noticeType)) {
final KycNoticeComposer.KycNotice stage = this.noticeComposer.compose(clientId, noticeType);
if (stage == null) {
throw validationError("noticeType", "This customer is not at a stage that has a notice: " + noticeType);
}
final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(stage);
subject = delivery.subject();
plainBody = delivery.body();
htmlTitle = stage.subject();
}
final String subject = "Action Required: KYC Verification Pending - " + this.bankName;
final String plainBody = buildPlainBody(client, exceptionReason, channel);
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
final String htmlBody = channel == KycContactChannel.EMAIL
? this.emailTemplateService.renderAlert(displayName(client), htmlTitle, plainBody)
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody)
: null;
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
@@ -143,63 +122,6 @@ public class KycContactLogServiceImpl implements KycContactLogService {
return toData(saved);
}
/**
* Its own transaction, so a notice that cannot be sent rolls back only itself. Joined to the queue job's
* transaction instead, one failure would mark the whole run rollback-only and every queued notice would
* stay waiting for ever.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public KycContactLogData sendStageNotice(final Long clientId, final String rawChannel, final String notes, final String officerName,
final String noticeType) {
final KycNoticeComposer.KycNotice stage = this.noticeComposer.compose(clientId, noticeType);
if (stage == null) {
throw validationError("noticeType", "The customer is no longer at a stage that has a notice: " + noticeType);
}
return sendNotice(clientId, rawChannel, notes, officerName, stage.subject(), stage.body());
}
@Override
@Transactional
public KycContactLogData sendNotice(final Long clientId, final String rawChannel, final String notes, final String officerName,
final String subject, final String body) {
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
if (channel == null || !channel.isPlatformSent()) {
throw validationError("channel", "Only SMS, Email and WhatsApp can be sent: " + rawChannel);
}
if (StringUtils.isBlank(notes) || StringUtils.isBlank(body)) {
throw validationError("notes", "A note and a message are required.");
}
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null);
final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null;
// A scheduled job has no logged-in user, so the user is optional here rather than required.
final AppUser currentUser = this.context.getAuthenticatedUserIfPresent();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel,
StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName,
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
final String fullSubject = subject + " - " + this.bankName;
final String plainBody = body.trim() + " - " + this.bankName;
final String htmlBody = channel == KycContactChannel.EMAIL
? this.emailTemplateService.renderAlert(displayName(client), subject, plainBody)
: null;
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, fullSubject, plainBody, htmlBody);
log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(),
result.failureReason(), plainBody);
if (!result.isDelivered()) {
LOG.warn("Automatic notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel, result.status(),
result.failureReason());
}
return toData(this.contactLogRepository.saveAndFlush(log));
}
@Override
@Transactional(readOnly = true)
public List<KycContactLogData> retrieveForClient(final Long clientId) {
@@ -1,186 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* The one place that decides what a customer is told, for each stage they can be at.
*
* <p>
* Kept apart from both the nightly reminder job and the officer's Notify button so the two can never
* word the same stage differently — and apart from the contact-log service so neither of them depends
* on the other.
*
* <p>
* The stage is worked out from the customer's own record on the day the notice is composed, not taken
* from the caller. A screen that is a few minutes stale therefore cannot send a notice for a stage the
* customer has already left.
*/
@Component
public class KycNoticeComposer {
/** The two families of notice: a re-KYC notice, or one about an account that has gone quiet. */
public static final String TYPE_KYC = "KYC";
public static final String TYPE_DORMANCY = "DORMANCY";
public static final int DEBIT_FREEZE_DAYS = 90;
public static final int INOPERATIVE_DAYS = 180;
public static final int SUB_STATUS_INACTIVE = 100;
public static final int SUB_STATUS_DORMANT = 200;
public static final int SUB_STATUS_ESCHEAT = 300;
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH);
/** Subject and plain-text body of one notice. */
public record KycNotice(String subject, String body) {
}
private final ClientKycRepository kycRepository;
private final DormantAccountReadPlatformService dormantAccountReadPlatformService;
@Value("${fineract.email.from-name:CBSnexora}")
private String bankName;
public KycNoticeComposer(final ClientKycRepository kycRepository,
final DormantAccountReadPlatformService dormantAccountReadPlatformService) {
this.kycRepository = kycRepository;
this.dormantAccountReadPlatformService = dormantAccountReadPlatformService;
}
/**
* The notice for the stage this customer is at today, or {@code null} when they are at none of the
* stages of that family (no KYC expiry on record, or no inactive / dormant / escheated account).
*/
public KycNotice compose(final Long clientId, final String type) {
if (TYPE_KYC.equalsIgnoreCase(type)) {
final ClientKyc kyc = kycRepository.findByClientId(clientId).orElse(null);
if (kyc == null || kyc.getExpiryDate() == null) {
return null;
}
final long offset = ChronoUnit.DAYS.between(kyc.getExpiryDate(), DateUtils.getBusinessLocalDate());
return new KycNotice(kycSubject(offset), kycMessage(kyc.getExpiryDate(), offset));
}
if (TYPE_DORMANCY.equalsIgnoreCase(type)) {
DormantAccountData worst = null;
for (final DormantAccountData account : dormantAccountReadPlatformService.retrieveDormantAccounts(null, null)) {
if (clientId.equals(account.getClientId()) && account.getSubStatus() != null
&& (worst == null || idle(account) > idle(worst))) {
worst = account;
}
}
return worst == null ? null
: new KycNotice(dormantSubject(worst), dormantMessage(worst, DateUtils.getBusinessLocalDate()));
}
return null;
}
/** The notice as it reaches the customer: the bank's name added to the subject and the sign-off. */
public KycNotice forDelivery(final KycNotice notice) {
return new KycNotice(notice.subject() + " - " + bankName, notice.body() + " - " + bankName);
}
// ── Re-KYC ─────────────────────────────────────────────────────────────────
public static String kycSubject(final long offset) {
if (offset < 0) {
return "Your KYC is due for renewal";
}
if (offset < DEBIT_FREEZE_DAYS) {
return "Your KYC has expired";
}
if (offset < INOPERATIVE_DAYS) {
return "Debits on your account are stopped";
}
return "Your account is inoperative";
}
/** What the customer is told, by the stage they are at: where they stand and what happens next. */
public static String kycMessage(final LocalDate expiry, final long offset) {
final String expired = expiry.format(DATE);
final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE);
final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE);
if (offset < 0) {
return "Your KYC expires on " + expired + " (in " + (-offset) + " days). Please visit your branch with valid ID and address"
+ " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze
+ " and the account will become inoperative on " + inoperative + ".";
}
if (offset < DEBIT_FREEZE_DAYS) {
return "Your KYC expired on " + expired + ". Please renew it at your branch now. If it is not renewed, debits on your"
+ " account will stop on " + freeze + " and the account will become inoperative on " + inoperative + ".";
}
if (offset < INOPERATIVE_DAYS) {
return "Your KYC expired on " + expired + " and debits on your account are now stopped. Please renew your KYC at your"
+ " branch; if it is not renewed, the account will become inoperative on " + inoperative + ".";
}
return "Your KYC expired on " + expired + " and your account is now inoperative. Please visit your branch with valid"
+ " documents to renew your KYC and have the account restored.";
}
// ── Inactive / dormant / escheated accounts ────────────────────────────────
public static String dormantSubject(final DormantAccountData account) {
final Integer sub = account.getSubStatus();
if (sub != null && sub == SUB_STATUS_ESCHEAT) {
return "Your account has been closed";
}
return sub != null && sub == SUB_STATUS_DORMANT ? "Your account is dormant" : "Your account is inactive";
}
public static String dormantMessage(final DormantAccountData account, final LocalDate today) {
final String since = account.getLastCustomerTransactionDate() != null ? account.getLastCustomerTransactionDate().format(DATE)
: "a long time";
final Integer sub = account.getSubStatus();
if (sub != null && sub == SUB_STATUS_ESCHEAT) {
return "Your account " + account.getAccountNo() + " has been closed after a very long period with no transaction by you"
+ " (last on " + since + "), and the balance has been transferred to RBI's Depositor Education and Awareness Fund."
+ " You can still claim it: please visit your branch with valid ID proof.";
}
if (sub != null && sub == SUB_STATUS_DORMANT) {
final String escheat = account.getDaysToEscheat() != null && account.getDaysToEscheat() > 0
? " If it stays dormant, the balance will be transferred to RBI's Depositor Education and Awareness Fund on "
+ today.plusDays(account.getDaysToEscheat()).format(DATE) + "."
: "";
return "Your account " + account.getAccountNo() + " is dormant: there has been no transaction by you since " + since
+ ", so debits are stopped. To reactivate it, please visit your branch with valid KYC documents to verify your"
+ " identity." + escheat;
}
final String dormant = account.getDaysToDormancy() != null && account.getDaysToDormancy() > 0
? " If there is still no transaction, it will become dormant on " + today.plusDays(account.getDaysToDormancy()).format(DATE)
+ " and debits will be stopped."
: "";
return "Your account " + account.getAccountNo() + " is inactive: there has been no transaction by you since " + since
+ ". Please make a transaction or visit your branch." + dormant;
}
private static long idle(final DormantAccountData account) {
return account.getDaysIdle() == null ? 0 : account.getDaysIdle();
}
}
@@ -1,65 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* Notices an officer has asked to be sent in the background.
*
* <p>
* The officer picks customers and submits; the notices wait in the queue until the queue job sends
* them. Each is sent exactly as an immediate Notify would be — the wording is for the stage the
* customer is at when the notice goes out, and it is recorded in the customer's contact log.
*/
public interface KycNoticeQueueService {
/** One customer and channel to notify. {@code noticeType} is KYC or DORMANCY. */
record QueueItem(Long clientId, String noticeType, String channel, String accountNo, String stageLabel) {
}
/** How many were queued, and how many were left out because the same notice was already waiting. */
record EnqueueResult(int queued, int skipped) {
}
/**
* Queues one notice per item.
*
* @param sendAt when to send; {@code null} means as soon as the queue job next runs
*/
EnqueueResult enqueue(List<QueueItem> items, String notes, String officerName, LocalDateTime sendAt);
/** Queue rows, newest first. {@code status} narrows to one of PENDING, SENT, FAILED, CANCELLED. */
List<Map<String, Object>> list(String status, int limit);
/** Withdraws a notice that has not been sent yet. */
void cancel(Long id);
/** Puts a failed notice back to be sent again. */
void retry(Long id);
/**
* Sends everything that is due. Called by the queue job.
*
* @return how many notices were sent
*/
int processDue();
}
@@ -1,238 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycContactLogData;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
private static final String PENDING = "PENDING";
private static final String SENDING = "SENDING";
private static final String SENT = "SENT";
private static final String FAILED = "FAILED";
private static final String CANCELLED = "CANCELLED";
/** A gateway that is down is tried again a couple of times, ten minutes apart, then given up on. */
private static final int MAX_ATTEMPTS = 3;
private static final int RETRY_MINUTES = 10;
/** A notice stuck mid-send (the server stopped) is put back after this long. */
private static final int STUCK_MINUTES = 15;
private static final int BATCH = 100;
private static final int MAX_ITEMS = 200;
private final JdbcTemplate jdbcTemplate;
private final KycContactLogService contactLogService;
private final KycNoticeComposer noticeComposer;
private final PlatformSecurityContext context;
@Override
public EnqueueResult enqueue(final List<QueueItem> items, final String notes, final String officerName, final LocalDateTime sendAt) {
if (items == null || items.isEmpty()) {
throw validationError("items", "Choose at least one customer.");
}
if (items.size() > MAX_ITEMS) {
throw validationError("items", "Queue at most " + MAX_ITEMS + " notices at a time.");
}
final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant();
final LocalDateTime when = sendAt == null || sendAt.isBefore(now) ? now : sendAt;
final String note = StringUtils.isBlank(notes) ? "Notice queued by an officer." : StringUtils.abbreviate(notes.trim(), 1000);
final AppUser user = this.context.getAuthenticatedUserIfPresent();
final Long userId = user != null ? user.getId() : null;
int queued = 0;
int skipped = 0;
for (final QueueItem item : items) {
final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel());
if (item.clientId() == null || channel == null || !channel.isPlatformSent()) {
throw validationError("channel", "Only SMS, Email and WhatsApp can be queued.");
}
final String type = item.noticeType() == null ? "" : item.noticeType().toUpperCase();
if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) {
throw validationError("noticeType", "Notice type must be KYC or DORMANCY.");
}
// The same notice already waiting is not queued twice; a double click must not message a customer twice.
final Integer waiting = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_kyc_notice_queue WHERE client_id = ? "
+ "AND channel = ? AND notice_type = ? AND status IN ('" + PENDING + "', '" + SENDING + "')", Integer.class,
item.clientId(), channel.name(), type);
if (waiting != null && waiting > 0) {
skipped++;
continue;
}
this.jdbcTemplate.update("INSERT INTO m_kyc_notice_queue (client_id, notice_type, channel, account_no, stage_label, notes, "
+ "officer_name, queued_by_user_id, status, attempts, scheduled_for, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
item.clientId(), type, channel.name(), StringUtils.abbreviate(item.accountNo(), 50),
StringUtils.abbreviate(item.stageLabel(), 100), note, StringUtils.abbreviate(officerName, 200), userId, PENDING,
Timestamp.valueOf(when), Timestamp.valueOf(now));
queued++;
}
return new EnqueueResult(queued, skipped);
}
@Override
public List<Map<String, Object>> list(final String status, final int limit) {
final int rows = Math.min(Math.max(limit, 1), 500);
final StringBuilder sql = new StringBuilder("SELECT q.id, q.client_id, c.display_name, q.account_no, q.notice_type, q.channel, "
+ "q.stage_label, q.status, q.attempts, q.result_message, q.scheduled_for, q.created_at, q.processed_at "
+ "FROM m_kyc_notice_queue q JOIN m_client c ON c.id = q.client_id");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
sql.append(" WHERE q.status = ?");
args.add(status.trim().toUpperCase());
}
sql.append(" ORDER BY q.id DESC LIMIT ").append(rows);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql.toString(), args.toArray())) {
final Map<String, Object> item = new LinkedHashMap<>();
item.put("id", r.get("id"));
item.put("clientId", r.get("client_id"));
item.put("clientName", r.get("display_name"));
item.put("accountNo", r.get("account_no"));
item.put("noticeType", r.get("notice_type"));
item.put("channel", r.get("channel"));
item.put("stageLabel", r.get("stage_label"));
item.put("status", r.get("status"));
item.put("attempts", r.get("attempts"));
item.put("resultMessage", r.get("result_message"));
item.put("scheduledFor", iso(r.get("scheduled_for")));
item.put("createdAt", iso(r.get("created_at")));
item.put("processedAt", iso(r.get("processed_at")));
out.add(item);
}
return out;
}
@Override
public void cancel(final Long id) {
final int changed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, processed_at = ?, "
+ "result_message = 'Cancelled before it was sent.' WHERE id = ? AND status = ?", CANCELLED,
Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant()), id, PENDING);
if (changed == 0) {
throw validationError("id", "Only a notice that is still waiting can be cancelled.");
}
}
@Override
public void retry(final Long id) {
final Timestamp now = Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant());
final int changed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = 0, scheduled_for = ?, "
+ "result_message = NULL, processed_at = NULL WHERE id = ? AND status = ?", PENDING, now, id, FAILED);
if (changed == 0) {
throw validationError("id", "Only a notice that failed can be retried.");
}
}
@Override
public int processDue() {
final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant();
// Anything left half-sent by a stopped server goes back in line.
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ? WHERE status = ? AND processed_at < ?", PENDING, SENDING,
Timestamp.valueOf(now.minusMinutes(STUCK_MINUTES)));
final List<Map<String, Object>> due = this.jdbcTemplate.queryForList("SELECT id, client_id, notice_type, channel, notes, "
+ "officer_name, attempts FROM m_kyc_notice_queue WHERE status = ? AND scheduled_for <= ? ORDER BY id LIMIT " + BATCH,
PENDING, Timestamp.valueOf(now));
int sent = 0;
for (final Map<String, Object> row : due) {
final Long id = ((Number) row.get("id")).longValue();
// Claim it first, so two overlapping runs cannot both send the same notice.
final int claimed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, processed_at = ? WHERE id = ? AND status = ?",
SENDING, Timestamp.valueOf(now), id, PENDING);
if (claimed == 0) {
continue;
}
final int attempts = ((Number) row.get("attempts")).intValue() + 1;
try {
// The stage is read when the notice goes out. A customer who has since transacted or renewed their KYC is
// no longer at a stage that has a notice, and telling them they are would be wrong — so it is dropped, not failed.
if (this.noticeComposer.compose(((Number) row.get("client_id")).longValue(), (String) row.get("notice_type")) == null) {
finish(id, CANCELLED, attempts, "Not sent — the customer is no longer at a stage that has a notice (they may have transacted or renewed their KYC since).", null, null);
continue;
}
final KycContactLogData result = this.contactLogService.sendStageNotice(((Number) row.get("client_id")).longValue(),
(String) row.get("channel"), (String) row.get("notes"), (String) row.get("officer_name"),
(String) row.get("notice_type"));
final String delivery = result.getDeliveryStatus();
if ("SENT".equals(delivery)) {
finish(id, SENT, attempts, result.getDeliveryMessage(), result.getId(), null);
sent++;
} else if ("FAILED".equals(delivery) && attempts < MAX_ATTEMPTS) {
// The gateway refused it; try again shortly rather than give up on the first miss.
finish(id, PENDING, attempts, result.getDeliveryMessage(), result.getId(),
Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant().plusMinutes(RETRY_MINUTES)));
} else {
finish(id, FAILED, attempts, result.getDeliveryMessage(), result.getId(), null);
}
} catch (final RuntimeException e) {
log.warn("Queued KYC notice {} could not be sent: {}", id, e.getMessage());
finish(id, FAILED, attempts, "Could not be sent: " + e.getMessage(), null, null);
}
}
if (!due.isEmpty()) {
log.info("[KYC notice queue] {} of {} due notice(s) sent", sent, due.size());
}
return sent;
}
private void finish(final Long id, final String status, final int attempts, final String message, final Long logId,
final Timestamp nextAttempt) {
final Timestamp now = Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant());
if (nextAttempt != null) {
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = ?, result_message = ?, contact_log_id = ?, "
+ "processed_at = ?, scheduled_for = ? WHERE id = ?", status, attempts, StringUtils.abbreviate(message, 500), logId, now,
nextAttempt, id);
} else {
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = ?, result_message = ?, contact_log_id = ?, "
+ "processed_at = ? WHERE id = ?", status, attempts, StringUtils.abbreviate(message, 500), logId, now, id);
}
}
private static String iso(final Object value) {
return value == null ? null : String.valueOf(value).replace(' ', 'T');
}
private PlatformApiDataValidationException validationError(final String parameter, final String message) {
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.notice.queue." + parameter + ".invalid",
message, parameter);
return new PlatformApiDataValidationException(List.of(error));
}
}
@@ -1,172 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
import org.springframework.stereotype.Service;
/**
* The nightly reminder pass.
*
* <p>
* The restriction job ({@link KycExpiryRestrictionService}) acts on accounts; this one tells the
* customer first, and tells them what comes next. A customer is therefore never surprised by a
* freeze they were not warned of.
*
* <p>
* The stage a customer is at is worked out from today's date every run rather than remembered, and
* the latest stage they have reached is the one they are told about. A missed night therefore costs
* nothing: the next run sends the current stage, not the stale one.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KycReminderServiceImpl implements KycReminderService {
/** Days from expiry (negative = before) at which a customer's KYC stage changes. */
private static final int[] KYC_MILESTONES = { -30, -15, -7, 0, 80, 90, 170, 180 };
private static final int SUB_STATUS_INACTIVE = 100;
private static final int SUB_STATUS_DORMANT = 200;
/** A failing gateway is retried on later nights, but not for ever. */
private static final long MAX_ATTEMPTS = 3;
private static final String OFFICER = "System — automatic reminder";
private static final List<KycContactChannel> CHANNELS = List.of(KycContactChannel.SMS, KycContactChannel.EMAIL);
private final ClientKycRepository kycRepository;
private final ClientKycContactLogRepository contactLogRepository;
private final KycContactLogService contactLogService;
private final DormantAccountReadPlatformService dormantAccountReadPlatformService;
@Override
public int sendReminders() {
final LocalDate today = DateUtils.getBusinessLocalDate();
int attempted = 0;
final List<ClientKyc> records = new ArrayList<>(
kycRepository.findExpiringBetween(today, today.plusDays(-KYC_MILESTONES[0]), ClientKycStatus.REJECTED.getValue()));
records.addAll(kycRepository.findLapsedAsOn(today, ClientKycStatus.REJECTED.getValue()));
for (final ClientKyc kyc : records) {
try {
attempted += remindKyc(kyc, today);
} catch (final RuntimeException e) {
// One customer's failure must not stop the rest of the night's reminders.
log.warn("KYC reminder for client {} failed: {}", kyc.getClient() != null ? kyc.getClient().getId() : null,
e.getMessage());
}
}
attempted += remindDormant(today);
log.info("[KYC reminders] {} notice(s) attempted", attempted);
return attempted;
}
// ── Re-KYC ─────────────────────────────────────────────────────────────────
private int remindKyc(final ClientKyc kyc, final LocalDate today) {
if (kyc.getClient() == null || kyc.getExpiryDate() == null) {
return 0;
}
final long offset = ChronoUnit.DAYS.between(kyc.getExpiryDate(), today);
Integer milestone = null;
for (final int m : KYC_MILESTONES) {
if (offset >= m) {
milestone = m;
}
}
if (milestone == null) {
return 0;
}
// The expiry date is in the marker so a renewed KYC starts a fresh set of reminders.
final String marker = "[AUTO KYC " + kyc.getExpiryDate() + " M" + milestone + "]";
return sendBoth(kyc.getClient().getId(), marker, KycNoticeComposer.kycSubject(offset),
KycNoticeComposer.kycMessage(kyc.getExpiryDate(), offset));
}
// ── Inactive / dormant accounts ────────────────────────────────────────────
private int remindDormant(final LocalDate today) {
// One notice per customer, about the account that has been quiet longest.
final Map<Long, DormantAccountData> worst = new LinkedHashMap<>();
for (final DormantAccountData account : dormantAccountReadPlatformService.retrieveDormantAccounts(null, null)) {
final Integer sub = account.getSubStatus();
if (account.getClientId() == null || sub == null || (sub != SUB_STATUS_INACTIVE && sub != SUB_STATUS_DORMANT)) {
continue; // escheated accounts are closed; there is nothing left to warn about
}
final DormantAccountData held = worst.get(account.getClientId());
if (held == null || idle(account) > idle(held)) {
worst.put(account.getClientId(), account);
}
}
int attempted = 0;
for (final DormantAccountData account : worst.values()) {
try {
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + "]";
final String subject = KycNoticeComposer.dormantSubject(account);
attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today));
} catch (final RuntimeException e) {
log.warn("Dormancy reminder for client {} failed: {}", account.getClientId(), e.getMessage());
}
}
return attempted;
}
private static long idle(final DormantAccountData account) {
return account.getDaysIdle() == null ? 0 : account.getDaysIdle();
}
// ── Sending ────────────────────────────────────────────────────────────────
/** Sends the message by every channel, skipping any channel this stage has already been told on. */
private int sendBoth(final Long clientId, final String marker, final String subject, final String body) {
int attempted = 0;
for (final KycContactChannel channel : CHANNELS) {
// Delivered, or nothing to deliver to, is final. A failure is retried, a few times only.
final boolean settled = contactLogRepository.existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(clientId,
channel, marker, EnumSet.of(KycContactDeliveryStatus.SENT, KycContactDeliveryStatus.SKIPPED_NO_DESTINATION));
if (settled || contactLogRepository.countByClientIdAndChannelAndNotesStartingWith(clientId, channel, marker) >= MAX_ATTEMPTS) {
continue;
}
contactLogService.sendNotice(clientId, channel.name(), marker + " Automatic reminder by " + channel.getDisplayLabel() + ".",
OFFICER, subject, body);
attempted++;
}
return attempted;
}
}
@@ -92,10 +92,7 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
txnRule != null ? txnRule.getMinLimitAmount() : null,
txnRule != null ? txnRule.getMaxLimitAmount() : null,
rule.isCcRelevant(),
rule.isOnTrial(),
rule.getTrialStartedOn() == null ? null : rule.getTrialStartedOn().toString()
txnRule != null ? txnRule.getMaxLimitAmount() : null
);
}
}
@@ -31,7 +31,4 @@ public interface KycRiskRulesWritePlatformService {
* @return the persisted rule exactly as the catalog GET would return it
*/
KycRiskRuleData createRule(KycRiskRuleRequest request);
/** Ends a rule's trial: it stays in the catalog as a normal rule. Returns the rule as the catalog GET would. */
KycRiskRuleData markNormal(Long ruleId);
}
@@ -28,7 +28,6 @@ import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrappe
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
@@ -82,13 +81,6 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
rule.setOccupation(occupation);
}
// A rule written for a Cash Credit account, or marked so, is listed under the CC chip.
rule.markCcRelevant(Boolean.TRUE.equals(request.getCcRelevant())
|| "CURRENT".equalsIgnoreCase(StringUtils.defaultString(request.getTxnAppliesTo()).trim()));
// A rule authored here starts on trial, from the business date, until an officer marks it normal.
rule.startTrial(DateUtils.getBusinessLocalDate());
final ClientRiskRule saved = this.repository.saveAndFlush(rule);
if (request.hasTransactionRule()) {
@@ -146,21 +138,8 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
final AmlTransactionRule transactionRule = AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo,
ruleType, request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
transactionRule.restrictTo(StringUtils.isBlank(request.getTxnDirection()) ? null : request.getTxnDirection().trim().toUpperCase(),
Boolean.TRUE.equals(request.getTxnCashOnly()));
return transactionRule;
}
@Override
@Transactional
public KycRiskRuleData markNormal(final Long ruleId) {
final ClientRiskRule rule = this.repository.findById(ruleId)
.orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found"));
rule.markNormal();
this.repository.saveAndFlush(rule);
return this.readPlatformService.retrieveRule(ruleId);
return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType,
request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
}
private Integer nextOrderPosition() {
@@ -203,10 +182,6 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class);
if (StringUtils.isNotBlank(request.getTxnDirection()) && !"CREDIT".equalsIgnoreCase(request.getTxnDirection().trim())
&& !"DEBIT".equalsIgnoreCase(request.getTxnDirection().trim())) {
validator.reset().parameter("txnDirection").value(request.getTxnDirection()).failWithCode("must.be.credit.or.debit");
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors);
@@ -64,9 +64,7 @@ public class ClientSearchService {
Pageable pageable = searchRequest.toPageable();
Long officeId = request.map(ClientTextSearch::getOfficeId).orElse(null);
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue, officeId)
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue)
.map(clientSearchDataMapper::map);
}
@@ -41,11 +41,4 @@ public class ClientTextSearch {
* not be able to link entities by omitting the param). Defaults to null = all legal forms.
*/
private String legalFormType;
/**
* Narrows results to one branch (office). It is applied on top of the caller's office-hierarchy
* restriction, never instead of it, so a branch user passing another branch's id still gets nothing.
* Used by the client list's branch filter for Head Office users. Null = no extra filter.
*/
private Long officeId;
}
@@ -0,0 +1,83 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationData;
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationDueData;
import org.apache.fineract.portfolio.investment.service.InvestmentAmortisationService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/amortisation")
@Component
@Tag(name = "Investment Premium Amortisation", description = "Premium of HTM holdings amortised straight-line to maturity every 31 March.")
@RequiredArgsConstructor
public class InvestmentAmortisationApiResource {
private final PlatformSecurityContext context;
private final InvestmentAmortisationService amortisationService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Amortisation schedule of a holding (06 Amortize Entry)")
public InvestmentAmortisationData schedule(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.amortisationService.schedule(purchaseId);
}
@GET
@Path("due")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Holdings due for amortisation on a 31 March (07 Amortize Yearly Process)", description = "fyEnd dd-MM-yyyy")
public List<InvestmentAmortisationDueData> due(@QueryParam("fyEnd") @Parameter(description = "dd-MM-yyyy") final String fyEnd) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.amortisationService.due(LocalDate.parse(fyEnd.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy")));
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post amortisation for a 31 March", description = "With purchaseId: that holding (Amortize Entry); without: "
+ "every holding due (Amortize Yearly Process). One JV per holding: Dr 500604 / Cr premium GL.")
public CommandProcessingResult amortise(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().amortiseInvestmentPremium().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import java.util.Map;
import java.util.Set;
/**
* Constants for the Treasury Investment masters (Bank / Party Master and Script Master), modelled on the bank's existing
* CBS Investment screens.
*/
public final class InvestmentApiConstants {
private InvestmentApiConstants() {}
public static final String ENTITY_PARTY = "INVESTMENTPARTY";
public static final String ENTITY_SCRIPT = "INVESTMENTSCRIPT";
public static final String RESOURCE_PARTY = "investmentparty";
public static final String RESOURCE_SCRIPT = "investmentscript";
/** Instrument ("Bond Type") an investment party / script belongs to. */
public static final Set<String> BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND", "FD", "SHARE");
/** Bond types that carry coupon terms (ISIN, interest rate, issue date are mandatory for these). */
public static final Set<String> DEBT_BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND");
/** SLR classification on the Script Master ("GOI" = Central Government). */
public static final Set<String> SLR_TYPES = Set.of("GOI", "STATE_GOVT", "TREASURY", "NON_SLR");
/** Script Code prefix per bond type, e.g. G00001 for the first Govt Security. */
public static final Map<String, String> SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND",
"M", "FD", "F", "SHARE", "S");
public static final String PARTY_CODE_PREFIX = "PTY-";
// Common parameters
public static final String BOND_TYPE = "bondType";
public static final String GL_ACCOUNT_ID = "glAccountId";
public static final String INTEREST_GL_ACCOUNT_ID = "interestGlAccountId";
public static final String ADDRESS1 = "address1";
public static final String ADDRESS2 = "address2";
public static final String ADDRESS3 = "address3";
public static final String CITY = "city";
public static final String PINCODE = "pincode";
public static final String PHONE = "phone";
public static final String FAX = "fax";
public static final String ACTIVE = "active";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
// Party parameters
public static final String PARTY_NAME = "partyName";
public static final String BANK_TYPE = "bankType";
public static final String RBI_CODE = "rbiCode";
public static final String EXPOSURE_LIMIT = "limit";
public static final String LEI_NO = "leiNo";
public static final String CP_BANK_NAME = "cpBankName";
public static final String CP_BRANCH_NAME = "cpBranchName";
public static final String CP_ACCOUNT_NO = "cpAccountNo";
public static final String CP_IFSC = "cpIfsc";
// Script parameters
public static final String SLR_TYPE = "slrType";
public static final String SCRIPT_DESC = "scriptDesc";
public static final String ISIN = "isin";
public static final String FACE_VALUE_PER_UNIT = "faceValuePerUnit";
public static final String INT_RATE = "intRate";
public static final String ISSUE_DATE = "issueDate";
public static final String MATURITY_DATE = "maturityDate";
public static final String COMPANY_NAME = "companyName";
public static final String CORP_CODE = "corpCode";
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX,
BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
LOCALE);
public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT,
INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE,
ACTIVE, LOCALE, DATE_FORMAT);
}
@@ -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.investment.api;
import java.util.Set;
/** Constants for the Daily Script Master (market rate of a script for a day). */
public final class InvestmentDailyRateApiConstants {
private InvestmentDailyRateApiConstants() {}
public static final String ENTITY = "INVESTMENTDAILYRATE";
public static final String RESOURCE = "investmentdailyrate";
public static final String SCRIPT_ID = "scriptId";
public static final String RATE_DATE = "rateDate";
public static final String OPEN_RATE = "openRate";
public static final String HIGH_RATE = "highRate";
public static final String LOW_RATE = "lowRate";
public static final String CLOSE_RATE = "closeRate";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> PARAMS = Set.of(SCRIPT_ID, RATE_DATE, OPEN_RATE, HIGH_RATE, LOW_RATE, CLOSE_RATE, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentDailyRateData;
import org.apache.fineract.portfolio.investment.service.InvestmentDailyRateReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/daily-rates")
@Component
@Tag(name = "Investment Daily Script Master", description = "Market rate of each script for a day; Purchase Entry takes its rate from here.")
@RequiredArgsConstructor
public class InvestmentDailyRateApiResource {
private final PlatformSecurityContext context;
private final InvestmentDailyRateReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List daily script rates, newest first")
public List<InvestmentDailyRateData> retrieveAll(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentDailyRateApiConstants.ENTITY);
return this.readPlatformService.retrieveAll(scriptId);
}
@GET
@Path("latest")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Newest rate of a script on or before a date", description = "onDate (dd-MM-yyyy) defaults to the business "
+ "date. Returns no content when no rate has been entered for the script.")
public InvestmentDailyRateData retrieveLatest(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
@QueryParam("onDate") @Parameter(description = "dd-MM-yyyy") final String onDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentDailyRateApiConstants.ENTITY);
final LocalDate on = StringUtils.isBlank(onDate) ? DateUtils.getBusinessLocalDate()
: LocalDate.parse(onDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.readPlatformService.retrieveLatest(scriptId, on);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save a daily script rate")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentDailyRate().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{rateId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop a daily script rate")
public CommandProcessingResult delete(@PathParam("rateId") @Parameter(description = "rateId") final Long rateId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentDailyRate(rateId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -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.investment.api;
import java.math.BigDecimal;
import java.util.Set;
/** Fixed deposits placed with other banks. */
public final class InvestmentFdApiConstants {
private InvestmentFdApiConstants() {}
public static final String ENTITY = "INVESTMENTFD";
public static final String BOND_TYPE_FD = "FD";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_MATURED = "MATURED";
public static final String STATUS_PREMATURE = "PREMATURE";
public static final String STATUS_RENEWED = "RENEWED";
/** When the interest is paid: all at maturity (the usual case), or every half year / year. */
public static final String PAYOUT_AT_MATURITY = "MATURITY";
public static final String PAYOUT_HALF_YEARLY = "HALF_YEARLY";
public static final String PAYOUT_YEARLY = "YEARLY";
public static final Set<String> PAYOUTS = Set.of(PAYOUT_AT_MATURITY, PAYOUT_HALF_YEARLY, PAYOUT_YEARLY);
/** How the maturity amount is worked out, to compare with the bank's figure (Jitendra Sir). */
public static final String CALC_SIMPLE = "SIMPLE";
public static final String CALC_MONTHLY = "MONTHLY";
public static final String CALC_QUARTERLY = "QUARTERLY";
public static final String CALC_HALF_YEARLY = "HALF_YEARLY";
public static final String CALC_YEARLY = "YEARLY";
public static final Set<String> CALC_TYPES = Set.of(CALC_SIMPLE, CALC_MONTHLY, CALC_QUARTERLY, CALC_HALF_YEARLY, CALC_YEARLY);
/** RBI: deposits with other banks within 20% of total deposits as on the previous 31 March, and within 5% with one bank. */
public static final BigDecimal GROSS_LIMIT_PERCENT = BigDecimal.valueOf(20);
public static final BigDecimal BANK_LIMIT_PERCENT = BigDecimal.valueOf(5);
/** Current accounts with banks also count towards the 20% inter-bank exposure. */
public static final String BANK_BALANCE_HEADER_GL_CODE = "100200";
/** Deposits of the bank, for the suggested total-deposits figure: everything under the deposit header, plus RD. */
public static final String DEPOSIT_HEADER_GL_CODE = "200100";
public static final String RD_DEPOSIT_GL_CODE = "200201";
public static final String PARTY_ID = "partyId";
public static final String CERT_NO = "certNo";
public static final String CERT_DATE = "certDate";
public static final String DEAL_DATE = "dealDate";
public static final String AMOUNT = "amount";
public static final String INT_RATE = "intRate";
public static final String MATURITY_DATE = "maturityDate";
public static final String PAYOUT = "payout";
public static final String CALC_TYPE = "calcType";
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
public static final String REMARKS = "remarks";
public static final String AS_ON_DATE = "asOnDate";
public static final String TOTAL_DEPOSITS = "totalDeposits";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final String CLOSURE_MATURITY = "MATURITY";
public static final String CLOSURE_PREMATURE = "PREMATURE";
public static final String CLOSURE_RENEWAL = "RENEWAL";
public static final String LINE_PROVISION = "PROVISION";
public static final String LINE_PAYOUT = "PAYOUT";
public static final String INTEREST_RECEIVABLE_GL_CODE = "100603";
/** Tax the other bank deducts from the interest, recoverable from the tax department. */
public static final String TDS_RECEIVABLE_GL_CODE = "100602";
public static final String CLOSE_DATE = "closeDate";
public static final String RECEIVED_AMOUNT = "receivedAmount";
public static final String TDS = "tds";
public static final String PENAL_AMOUNT = "penalAmount";
public static final String PRINCIPAL_BANK_GL_ACCOUNT_ID = "principalBankGlAccountId";
public static final String INTEREST_BANK_GL_ACCOUNT_ID = "interestBankGlAccountId";
public static final String NEW_CERT_NO = "newCertNo";
public static final String NEW_CERT_DATE = "newCertDate";
public static final String NEW_INT_RATE = "newIntRate";
public static final String NEW_MATURITY_DATE = "newMaturityDate";
public static final String NEW_PAYOUT = "newPayout";
public static final String NEW_CALC_TYPE = "newCalcType";
public static final String MATURITY_AMOUNT = "maturityAmount";
public static final String RENEWAL_AMOUNT = "renewalAmount";
public static final String DIFF_BANK_GL_ACCOUNT_ID = "diffBankGlAccountId";
public static final String LINE_MATURITY = "MATURITY";
public static final String LINE_TYPE = "lineType";
public static final String INT_DATE = "intDate";
/** Reminders show lines due within this many days (as for Govt securities). */
public static final int DUE_WITHIN_DAYS = 2;
public static final Set<String> INTEREST_PARAMS = Set.of(LINE_TYPE, INT_DATE, RECEIVED_AMOUNT, TDS, BANK_GL_ACCOUNT_ID, LOCALE,
DATE_FORMAT);
public static final Set<String> MATURE_PARAMS = Set.of(RECEIVED_AMOUNT, TDS, PRINCIPAL_BANK_GL_ACCOUNT_ID, INTEREST_BANK_GL_ACCOUNT_ID,
REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> PREMATURE_PARAMS = Set.of(CLOSE_DATE, RECEIVED_AMOUNT, TDS, PENAL_AMOUNT, PRINCIPAL_BANK_GL_ACCOUNT_ID,
INTEREST_BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> RENEW_PARAMS = Set.of(NEW_CERT_NO, NEW_CERT_DATE, NEW_INT_RATE, NEW_MATURITY_DATE, NEW_PAYOUT,
NEW_CALC_TYPE, MATURITY_AMOUNT, RENEWAL_AMOUNT, TDS, DIFF_BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> FD_PARAMS = Set.of(PARTY_ID, CERT_NO, CERT_DATE, DEAL_DATE, AMOUNT, INT_RATE, MATURITY_DATE, PAYOUT,
CALC_TYPE, BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> BASE_PARAMS = Set.of(AS_ON_DATE, TOTAL_DEPOSITS, REMARKS, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,186 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentFdClosureData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdDueData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData;
import org.apache.fineract.portfolio.investment.service.InvestmentFdClosureService;
import org.apache.fineract.portfolio.investment.service.InvestmentFdInterestService;
import org.apache.fineract.portfolio.investment.service.InvestmentFdService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/fds")
@Component
@Tag(name = "Investment FD", description = "Fixed deposits the bank places with other banks.")
@RequiredArgsConstructor
public class InvestmentFdApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentFdService fdService;
private final InvestmentFdClosureService closureService;
private final InvestmentFdInterestService interestService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "All FDs placed with other banks")
public List<InvestmentFdData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.fdService.retrieveAll();
}
@GET
@Path("quote")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Preview an FD: interest, maturity amount and the RBI limits", description = "Dates dd-MM-yyyy")
public InvestmentFdData quote(@QueryParam("partyId") final Long partyId, @QueryParam("amount") final BigDecimal amount,
@QueryParam("intRate") final BigDecimal intRate, @QueryParam("dealDate") final String dealDate,
@QueryParam("maturityDate") final String maturityDate, @QueryParam("payout") final String payout,
@QueryParam("calcType") final String calcType) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.fdService.quote(partyId, amount, intRate, date(dealDate), date(maturityDate), payout, calcType);
}
@GET
@Path("due")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "FD interest vouchers and maturities due", description = "Due now or within withinDays (default 2)")
public List<InvestmentFdDueData> due(@QueryParam("withinDays") final Integer withinDays) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.interestService.due(withinDays == null ? InvestmentFdApiConstants.DUE_WITHIN_DAYS : withinDays);
}
@GET
@Path("{fdId}/interest-schedule")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Interest lines of an FD", description = "31 Mar / 30 Sep provisions, payouts and maturity")
public InvestmentFdScheduleData schedule(@PathParam("fdId") final Long fdId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.interestService.schedule(fdId);
}
@POST
@Path("{fdId}/interest")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post the next interest line of an FD", description = "lineType PROVISION | PAYOUT, intDate; payout: receivedAmount, tds")
public CommandProcessingResult postInterest(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return this.commandsSourceWritePlatformService
.logCommandSource(new CommandWrapperBuilder().postInvestmentFdInterest(fdId).withJson(apiRequestBodyAsJson).build());
}
@GET
@Path("{fdId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "One FD")
public InvestmentFdData retrieveOne(@PathParam("fdId") @Parameter(description = "fdId") final Long fdId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.fdService.retrieveOne(fdId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Place an FD", description = "Dr the bank's FD GL / Cr our bank GL, dated the deal date; refused beyond the RBI limits.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentFd().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@GET
@Path("{fdId}/closure-quote")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Preview a maturity, premature closure or renewal", description = "type MATURITY | PREMATURE | RENEWAL")
public InvestmentFdClosureData closureQuote(@PathParam("fdId") final Long fdId, @QueryParam("type") final String type,
@QueryParam("closeDate") final String closeDate, @QueryParam("receivedAmount") final BigDecimal receivedAmount,
@QueryParam("tds") final BigDecimal tds, @QueryParam("renewalAmount") final BigDecimal renewalAmount) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.closureService.quote(fdId, type, date(closeDate), receivedAmount, tds, renewalAmount);
}
@POST
@Path("{fdId}/mature")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Close an FD at maturity", description = "Dated the maturity date; amount as credited by the bank, optional TDS.")
public CommandProcessingResult mature(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return this.commandsSourceWritePlatformService
.logCommandSource(new CommandWrapperBuilder().matureInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
}
@POST
@Path("{fdId}/premature")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Close an FD before maturity", description = "Not calculated: the amount received (and any penal interest) is entered.")
public CommandProcessingResult premature(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return this.commandsSourceWritePlatformService
.logCommandSource(new CommandWrapperBuilder().prematureInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
}
@POST
@Path("{fdId}/renew")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Renew an FD at maturity", description = "New certificate, rate and maturity; money added or taken out goes through a bank GL.")
public CommandProcessingResult renew(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return this.commandsSourceWritePlatformService
.logCommandSource(new CommandWrapperBuilder().renewInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
}
@POST
@Path("deposit-base")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record total deposits as on a 31 March", description = "The base of the RBI limits for the following year.")
public CommandProcessingResult setDepositBase(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().setInvestmentDepositBase().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
private static LocalDate date(final String value) {
return StringUtils.isBlank(value) ? null : LocalDate.parse(value.trim(), DATE);
}
}
@@ -0,0 +1,51 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import java.util.Set;
/** Constants for posting an interest schedule line (coupon or closing provision) of a holding to GL. */
public final class InvestmentInterestApiConstants {
private InvestmentInterestApiConstants() {}
public static final String ENTITY = "INVESTMENTINTEREST";
public static final String RESOURCE = "investmentinterest";
public static final String LINE_TYPE = "lineType";
public static final String INT_DATE = "intDate";
/** Bank GL the coupon is received into; defaults to the purchase's paying bank. */
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> PARAMS = Set.of(LINE_TYPE, INT_DATE, BANK_GL_ACCOUNT_ID, LOCALE, DATE_FORMAT);
/** A line shows as a task this many days before its date (Jitendra Sir). */
public static final int DUE_WITHIN_DAYS = 2;
public static final String INTEREST_RECEIVABLE_GL_CODE = "100603";
public static final String INTEREST_INCOME_GOVT_SECURITIES_GL_CODE = "400401";
public static final String INTEREST_INCOME_BONDS_GL_CODE = "400402";
/** Income GL for a holding's interest: SLR govt securities, or bonds / debentures for the rest. */
public static String interestIncomeGlCode(final String bondType) {
return "GOVT_SECURITY".equals(bondType) ? INTEREST_INCOME_GOVT_SECURITIES_GL_CODE : INTEREST_INCOME_BONDS_GL_CODE;
}
}
@@ -0,0 +1,65 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestDueData;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestPostingData;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/interest-postings")
@Component
@Tag(name = "Investment Interest Posting", description = "Interest lines of holdings that are due to be posted with a voucher.")
@RequiredArgsConstructor
public class InvestmentInterestApiResource {
private final PlatformSecurityContext context;
private final InvestmentPurchaseReadPlatformService readPlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Posted interest lines", description = "Coupons and closing provisions already posted to GL with their "
+ "voucher, newest first.")
public List<InvestmentInterestPostingData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentInterestApiConstants.ENTITY);
return this.readPlatformService.retrieveInterestPostings();
}
@GET
@Path("due")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Interest lines due", description = "For every active holding, its next unposted interest line when that "
+ "line's date is past or within withinDays days (default 2).")
public List<InvestmentInterestDueData> due(@QueryParam("withinDays") @Parameter(description = "withinDays") final Integer withinDays) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentInterestApiConstants.ENTITY);
return this.readPlatformService
.retrieveDueInterest(withinDays == null ? InvestmentInterestApiConstants.DUE_WITHIN_DAYS : withinDays);
}
}
@@ -0,0 +1,99 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentPartyData;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/parties")
@Component
@Tag(name = "Investment Bank / Party Master", description = "Counterparties an investment is bought from or placed with.")
@RequiredArgsConstructor
public class InvestmentPartyApiResource {
private final PlatformSecurityContext context;
private final InvestmentMasterReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment parties")
public List<InvestmentPartyData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
return this.readPlatformService.retrieveAllParties();
}
@GET
@Path("{partyId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment party")
public InvestmentPartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_PARTY);
return this.readPlatformService.retrieveParty(partyId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create an investment party")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentParty().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@PUT
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update an investment party")
public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentParty(partyId).withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{partyId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop an investment party")
public CommandProcessingResult delete(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentParty(partyId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import java.util.Set;
/** Constants for the Investment Purchase Entry (a deal that creates a holding). */
public final class InvestmentPurchaseApiConstants {
private InvestmentPurchaseApiConstants() {}
public static final String ENTITY = "INVESTMENTPURCHASE";
public static final String RESOURCE = "investmentpurchase";
public static final Set<String> SETTLEMENT_TYPES = Set.of("T+0", "T+1", "T+2");
public static final Set<String> CATEGORIES = Set.of("HTM (Held To Maturity)", "AFS (Available For Sale)", "HFT (Held For Trading)");
/** Saved deal; nothing posted to GL until it is settled. */
public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
/** Settled: posted to GL on the settlement date, now a holding. */
public static final String STATUS_ACTIVE = "ACTIVE";
/** Every unit sold before maturity. */
public static final String STATUS_SOLD = "SOLD";
/** Repaid at maturity. */
public static final String STATUS_REDEEMED = "REDEEMED";
/** GLs the settlement entry debits besides the script's investment GL. */
public static final String BROKEN_PERIOD_INTEREST_GL_CODE = "500606";
public static final String STAMP_DUTY_GL_CODE = "500601";
public static final String BROKERAGE_GL_CODE = "500602";
public static final String OTHER_CHARGES_GL_CODE = "500603";
public static final String BOND_TYPE = "bondType";
public static final String SCRIPT_ID = "scriptId";
public static final String PARTY_ID = "partyId";
public static final String DEAL_DATE = "dealDate";
public static final String DEAL_TIME = "dealTime";
public static final String SETTLEMENT_TYPE = "settlementType";
public static final String FACE_VALUE = "faceValue";
public static final String PURCHASE_RATE = "purchaseRate";
public static final String CONSIDER_AMT = "considerAmt";
public static final String NAV = "nav";
public static final String STAMP_DUTY = "stampDuty";
public static final String BROKERAGE = "brokerage";
public static final String OTHER_CHARGES = "otherCharges";
public static final String CATEGORY = "category";
public static final String LAST_INT_DATE = "lastIntDate";
public static final String CR_GL_ACCOUNT_ID = "crGlAccountId";
public static final String SUB_TYPE = "subType";
/** Optional legacy fields (trade no, authorities, lien, delivery mode, …) kept as a JSON object. */
public static final String OTHER_DETAILS = "otherDetails";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, OTHER_CHARGES, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE,
OTHER_DETAILS, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,184 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/purchases")
@Component
@Tag(name = "Investment Purchase Entry", description = "Investment deals (Govt Security, Bond, Mutual Fund, …) — each becomes a holding.")
@RequiredArgsConstructor
public class InvestmentPurchaseApiResource {
private final PlatformSecurityContext context;
private final InvestmentPurchaseReadPlatformService readPlatformService;
private final InvestmentSaleReadPlatformService saleReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment purchases (holdings)")
public List<InvestmentPurchaseData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
return this.readPlatformService.retrieveAll();
}
@GET
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment purchase")
public InvestmentPurchaseData retrieveOne(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
return this.readPlatformService.retrieveOne(purchaseId);
}
@GET
@Path("{purchaseId}/interest-schedule")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Interest schedule of a holding", description = "Half-yearly coupons and 31 Mar / 30 Sep closing provisions "
+ "from purchase to maturity. Calculated only — nothing is posted. asOnDate (dd-MM-yyyy) defaults to the business date.")
public InvestmentInterestScheduleData retrieveInterestSchedule(
@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null
: LocalDate.parse(asOnDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save a purchase entry")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentPurchase().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{purchaseId}/settle")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Settle a purchase", description = "On or after the settlement date: posts the purchase to GL dated the "
+ "settlement date and makes the deal an active holding.")
public CommandProcessingResult settle(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().settleInvestmentPurchase(purchaseId)
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{purchaseId}/interest-postings")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post an interest line", description = "Posts the next interest schedule line (coupon or closing provision) "
+ "of a holding to GL with a JV voucher, on or after its date and in date order.")
public CommandProcessingResult postInterest(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().postInvestmentInterest(purchaseId).withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@GET
@Path("{purchaseId}/redemption-quote")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "What the redemption at maturity will post", description = "Face value back against the book value still "
+ "held; refused before maturity or while a sale is pending.")
public InvestmentSaleData redemptionQuote(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.saleReadPlatformService.quoteRedemption(purchaseId);
}
@POST
@Path("{purchaseId}/redeem")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Redeem at maturity", description = "On or after maturity, once every coupon is posted: posts the face value "
+ "received, dated the maturity date, and closes the holding.")
public CommandProcessingResult redeem(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().redeemInvestmentPurchase(purchaseId)
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{purchaseId}/write-off")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Write off part of the book value", description = "Dr Loss on Investments / Cr Investment for the amount "
+ "given; the units stay held at the reduced book value.")
public CommandProcessingResult writeOff(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().writeOffInvestmentPurchase(purchaseId).withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{purchaseId}/transfer")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "GOI Category Transfer", description = "Moves the holding to another security (scriptId) of the same coupon "
+ "rate, maturity and unit value; the book value moves between GLs only when the two scripts use different GLs.")
public CommandProcessingResult transfer(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().transferInvestmentSecurity(purchaseId).withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop a purchase entry")
public CommandProcessingResult delete(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentPurchase(purchaseId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,70 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import java.util.Set;
/** Sale (before maturity) and redemption (at maturity) of investment holdings. */
public final class InvestmentSaleApiConstants {
private InvestmentSaleApiConstants() {}
public static final String ENTITY = "INVESTMENTSALE";
public static final String RESOURCE = "investmentsale";
public static final String TYPE_SELL = "SELL";
public static final String TYPE_REDEEM = "REDEEM";
/** Part of the book value written off as a loss; the units stay held. */
public static final String TYPE_WRITEOFF = "WRITEOFF";
/** A year-end amortisation of premium (31 March); recorded like a sale so the book value comes down. */
public static final String TYPE_AMORTISE = "AMORTISE";
public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
public static final String STATUS_SETTLED = "SETTLED";
/** Profit on sale / redemption (income) and loss on sale / redemption (expense). */
public static final String PROFIT_ON_SALE_GL_CODE = "400405";
public static final String LOSS_ON_SALE_GL_CODE = "500605";
/** Premium still on the books at maturity is written off to the amortisation expense. */
public static final String PREMIUM_AMORTISATION_GL_CODE = "500604";
/** Premium paid over face value, held here until amortised (Jitendra Sir). */
public static final String PREMIUM_ON_INVESTMENTS_GL_CODE = "100707";
public static final String PURCHASE_ID = "purchaseId";
public static final String PARTY_ID = "partyId";
public static final String DEAL_DATE = "dealDate";
public static final String DEAL_TIME = "dealTime";
public static final String SETTLEMENT_TYPE = "settlementType";
public static final String UNITS = "units";
public static final String SALE_RATE = "saleRate";
public static final String BROKERAGE = "brokerage";
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
public static final String SANCTION_AUTHORITY = "sanctionAuthority";
public static final String REMARKS = "remarks";
public static final String AMOUNT = "amount";
public static final String FY_END = "fyEnd";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> SELL_PARAMS = Set.of(PURCHASE_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, UNITS, SALE_RATE,
BROKERAGE, BANK_GL_ACCOUNT_ID, SANCTION_AUTHORITY, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> REDEEM_PARAMS = Set.of(BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> WRITEOFF_PARAMS = Set.of(AMOUNT, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> AMORTISE_PARAMS = Set.of(PURCHASE_ID, FY_END, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,116 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/sales")
@Component
@Tag(name = "Investment Sale", description = "Sale of holdings before maturity (all or part of the units), settled on the settlement date.")
@RequiredArgsConstructor
public class InvestmentSaleApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentSaleReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List sales and redemptions, newest first")
public List<InvestmentSaleData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.readPlatformService.retrieveAll();
}
@GET
@Path("quote")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Work out a sale before saving it", description = "Price, interest from the buyer, cost, profit or loss "
+ "and the net amount for the units, rate and deal date given (dd-MM-yyyy).")
public InvestmentSaleData quote(@QueryParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
@QueryParam("settlementType") @Parameter(description = "T+0 / T+1 / T+2") final String settlementType,
@QueryParam("units") @Parameter(description = "units") final BigDecimal units,
@QueryParam("saleRate") @Parameter(description = "saleRate") final BigDecimal saleRate,
@QueryParam("brokerage") @Parameter(description = "brokerage") final BigDecimal brokerage,
@QueryParam("bankGlAccountId") @Parameter(description = "bankGlAccountId") final Long bankGlAccountId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
final LocalDate deal = LocalDate.parse(dealDate.trim(), DATE);
return this.readPlatformService.quoteSale(purchaseId, deal, StringUtils.defaultIfBlank(settlementType, "T+0"), units, saleRate,
brokerage, bankGlAccountId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save a sale", description = "Saved pending; nothing reaches the GL until it is settled.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentSale().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{saleId}/settle")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Settle a sale", description = "On or after the settlement date: posts the sale to GL dated the settlement "
+ "date and reduces the units held.")
public CommandProcessingResult settle(@PathParam("saleId") @Parameter(description = "saleId") final Long saleId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().settleInvestmentSale(saleId)
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{saleId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop a pending sale")
public CommandProcessingResult delete(@PathParam("saleId") @Parameter(description = "saleId") final Long saleId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentSale(saleId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,89 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentScriptData;
import org.apache.fineract.portfolio.investment.service.InvestmentMasterReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/scripts")
@Component
@Tag(name = "Investment Script Master", description = "Securities / instruments the bank invests in (Govt Security, Bond, Mutual Fund, …).")
@RequiredArgsConstructor
public class InvestmentScriptApiResource {
private final PlatformSecurityContext context;
private final InvestmentMasterReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment scripts")
public List<InvestmentScriptData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
return this.readPlatformService.retrieveAllScripts();
}
@GET
@Path("{scriptId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve an investment script")
public InvestmentScriptData retrieveOne(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentApiConstants.ENTITY_SCRIPT);
return this.readPlatformService.retrieveScript(scriptId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create an investment script")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentScript().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@PUT
@Path("{scriptId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update an investment script")
public CommandProcessingResult update(@PathParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().updateInvestmentScript(scriptId).withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -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.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentSecurityTransferData;
import org.apache.fineract.portfolio.investment.service.InvestmentSecurityTransferService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/transfers")
@Component
@Tag(name = "Investment Security Transfer", description = "GOI Category Transfer: a holding moved to another security of the same terms.")
@RequiredArgsConstructor
public class InvestmentTransferApiResource {
private final PlatformSecurityContext context;
private final InvestmentSecurityTransferService transferService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List security transfers, newest first")
public List<InvestmentSecurityTransferData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission("INVESTMENTTRANSFER");
return this.transferService.retrieveAll();
}
}
@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/** Premium amortisation schedule of an HTM holding: one line per 31 March to maturity, then the rest at maturity. */
@Getter
@Builder
public class InvestmentAmortisationData {
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final String category;
private final String settlementDate;
private final String maturityDate;
private final BigDecimal faceValue;
private final BigDecimal premium;
private final BigDecimal unamortisedPremium;
private final BigDecimal bookValue;
private final String premiumGlCode;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
/** YEAR (31 March) or MATURITY (the remainder, written off on redemption). */
private final String kind;
private final String fyEnd;
private final String fromDate;
private final String toDate;
private final long days;
private final BigDecimal amount;
private final BigDecimal premiumAfter;
private final boolean posted;
private final String voucherNo;
private final String glTransactionId;
/** Next line to post and its 31 March has come. */
private final boolean postable;
}
}
@@ -16,24 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
package org.apache.fineract.portfolio.investment.data;
/**
* Sends customers the reminder that matches the stage they have reached, and says what happens to
* their account next.
*/
public interface KycReminderService {
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/**
* One pass over every customer whose re-KYC is within 30 days of falling due or already lapsed,
* and every customer with an inactive or dormant savings account.
*
* <p>
* Each customer gets the latest stage notice they have not yet been sent, by SMS and by email, and
* a notice is never repeated for the same stage. Running the pass twice in a day, or after a
* missed night, sends nothing extra.
*
* @return how many notices were attempted
*/
int sendReminders();
/** A holding's premium amortisation due for a 31 March (Amortize Yearly Process). */
@Getter
@Builder
public class InvestmentAmortisationDueData {
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final String fyEnd;
private final String fromDate;
private final long days;
private final BigDecimal unamortisedPremium;
private final BigDecimal amount;
}
@@ -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.investment.data;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate;
@Getter
@Builder
public class InvestmentDailyRateData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final Long scriptId;
private final String scriptCode;
private final String scriptDesc;
private final String bondType;
private final String rateDate;
private final BigDecimal openRate;
private final BigDecimal highRate;
private final BigDecimal lowRate;
private final BigDecimal closeRate;
public static InvestmentDailyRateData from(final InvestmentDailyRate r) {
return InvestmentDailyRateData.builder().id(r.getId()).scriptId(r.getScript().getId()).scriptCode(r.getScript().getScriptCode())
.scriptDesc(r.getScript().getScriptDesc()).bondType(r.getScript().getBondType())
.rateDate(r.getRateDate().format(DATE_FORMAT)).openRate(r.getOpenRate()).highRate(r.getHighRate()).lowRate(r.getLowRate())
.closeRate(r.getCloseRate()).build();
}
}
@@ -0,0 +1,65 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
/** What closing an FD (maturity, premature or renewal) works out to, and the voucher it posts. */
@Getter
@Builder
public class InvestmentFdClosureData {
private final Long fdId;
private final String dealNo;
private final String type;
private final String closeDate;
private final BigDecimal principal;
/** Interest the system works out for the period, to compare with the bank's figure. */
private final BigDecimal expectedInterest;
/** Interest already booked by provisions and not yet received. */
private final BigDecimal provisioned;
/** Money credited to our bank by the other bank (net of TDS). */
private final BigDecimal receivedAmount;
private final BigDecimal tds;
private final BigDecimal penalAmount;
/** Gross interest as per the bank: received + TDS − principal. */
private final BigDecimal interest;
/** Interest income of this entry: interest less what was provisioned (negative = reversed from income). */
private final BigDecimal income;
/** Bank's interest less the system's. */
private final BigDecimal difference;
private final BigDecimal renewalAmount;
/** Renewal: money added (positive) or taken out (negative) against the maturity amount. */
private final BigDecimal topUp;
private final List<Line> lines;
@Getter
@AllArgsConstructor
public static class Line {
private final String side;
private final String glCode;
private final String glName;
private final BigDecimal amount;
}
}
@@ -0,0 +1,103 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.portfolio.investment.domain.InvestmentFd;
/** An FD placed with another bank; also the shape of a purchase preview (with the limit check). */
@Getter
@Builder
public class InvestmentFdData {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final String dealNo;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final String certNo;
private final String certDate;
private final String dealDate;
private final BigDecimal principal;
private final BigDecimal intRate;
private final String maturityDate;
private final long days;
private final String payout;
private final String calcType;
private final BigDecimal expectedInterest;
private final BigDecimal expectedMaturityAmount;
/** Interest paid each half year / year for a payout FD. */
private final BigDecimal periodInterest;
private final String fdGlCode;
private final String fdGlName;
private final String interestGlCode;
private final String interestGlName;
private final Long bankGlAccountId;
private final String bankGlCode;
private final String bankGlName;
private final String status;
private final String remarks;
private final String glTransactionId;
private final String voucherNo;
private final String renewedFromDealNo;
private final String closedOnDate;
private final String closureType;
private final BigDecimal closureAmount;
private final BigDecimal closureInterest;
private final BigDecimal closureTds;
private final BigDecimal closurePenal;
private final String closureVoucherNo;
private final String closureGlTransactionId;
private final InvestmentFdLimitData limit;
public static InvestmentFdData from(final InvestmentFd f, final BigDecimal periodInterest) {
return builder().id(f.getId()).dealNo(f.getDealNo()).partyId(f.getParty().getId()).partyCode(f.getParty().getPartyCode())
.partyName(f.getParty().getPartyName()).certNo(f.getCertNo()).certDate(format(f.getCertDate()))
.dealDate(format(f.getDealDate())).principal(f.getPrincipal()).intRate(f.getIntRate())
.maturityDate(format(f.getMaturityDate())).days(ChronoUnit.DAYS.between(f.getDealDate(), f.getMaturityDate()))
.payout(f.getPayout()).calcType(f.getCalcType()).expectedInterest(f.getExpectedInterest())
.expectedMaturityAmount(f.getExpectedMaturityAmount()).periodInterest(periodInterest).fdGlCode(code(f.getFdGlAccount()))
.fdGlName(name(f.getFdGlAccount())).interestGlCode(code(f.getInterestGlAccount())).interestGlName(name(f.getInterestGlAccount()))
.bankGlAccountId(f.getBankGlAccount().getId()).bankGlCode(code(f.getBankGlAccount())).bankGlName(name(f.getBankGlAccount()))
.status(f.getStatus()).remarks(f.getRemarks()).glTransactionId(f.getGlTransactionId()).voucherNo(f.getVoucherNo())
.renewedFromDealNo(f.getRenewedFrom() == null ? null : f.getRenewedFrom().getDealNo()).closedOnDate(format(f.getClosedOnDate()))
.closureType(f.getClosureType()).closureAmount(f.getClosureAmount()).closureInterest(f.getClosureInterest())
.closureTds(f.getClosureTds()).closurePenal(f.getClosurePenal()).closureVoucherNo(f.getClosureVoucherNo()).closureGlTransactionId(f.getClosureGlTransactionId()).build();
}
private static String code(final GLAccount gl) {
return gl == null ? null : gl.getGlCode();
}
private static String name(final GLAccount gl) {
return gl == null ? null : gl.getName();
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE);
}
}
@@ -0,0 +1,40 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/** The next interest voucher (or the maturity) of an FD that is due, or due within the reminder window. */
@Getter
@Builder
public class InvestmentFdDueData {
private final Long fdId;
private final String dealNo;
private final String partyName;
private final String certNo;
/** PROVISION, PAYOUT or MATURITY. */
private final String lineType;
private final String intDate;
private final BigDecimal amount;
private final boolean postable;
private final boolean overdue;
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/** Where a new FD stands against the RBI limits on deposits with other banks. */
@Getter
@Builder
public class InvestmentFdLimitData {
/** The 31 March the limits are based on. */
private final String asOnDate;
/** Total deposits as on that date, as entered; empty until someone enters it. */
private final BigDecimal totalDeposits;
/** The same figure worked out from the deposit GLs, as a suggestion. */
private final BigDecimal glTotalDeposits;
private final BigDecimal grossLimit;
private final BigDecimal fdOutstanding;
private final BigDecimal bankBalances;
private final BigDecimal grossAfter;
private final BigDecimal bankLimit;
private final String partyName;
private final BigDecimal bankAfter;
private final BigDecimal newAmount;
private final boolean withinLimits;
private final String message;
}
@@ -0,0 +1,58 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/** Interest lines of an FD: 31 Mar / 30 Sep provisions, payouts of a half-yearly / yearly FD, and maturity. */
@Getter
@Builder
public class InvestmentFdScheduleData {
private final InvestmentFdData fd;
/** Provisioned interest not yet received (interest receivable of this FD). */
private final BigDecimal provisioned;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
/** PROVISION, PAYOUT or MATURITY (shown for information; closed on the Maturity screen). */
private final String lineType;
private final String intDate;
private final String fromDate;
private final String toDate;
private final long days;
private final BigDecimal grossInterest;
/** Payout: the part already provisioned, cleared from interest receivable. */
private final BigDecimal alreadyBooked;
private final BigDecimal tds;
/** Provision: interest booked. Payout: interest income of the payout (gross less already booked). */
private final BigDecimal amount;
private final boolean posted;
private final String voucherNo;
private final String glTransactionId;
/** The next line to post, and its date has come. */
private final boolean postable;
}
}
@@ -0,0 +1,43 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/** The next unposted interest line of a holding that is due, or due within the reminder window — a task to post. */
@Getter
@Builder
public class InvestmentInterestDueData {
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final String lineType;
private final String intDate;
private final BigDecimal grossInterest;
private final BigDecimal amount;
/** The date has come: it can be posted now. */
private final boolean postable;
/** The date has passed without posting. */
private final boolean overdue;
}
@@ -0,0 +1,51 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/** An interest line (coupon or closing provision) already posted to GL with its voucher — for the transaction log. */
@Getter
@Builder
public class InvestmentInterestPostingData {
private final Long id;
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String bondType;
private final String scriptDesc;
private final String lineType;
private final String intDate;
private final String fromDate;
private final String toDate;
private final BigDecimal grossInterest;
private final BigDecimal alreadyBooked;
private final BigDecimal amount;
/** Debit side: the bank for a coupon, interest receivable for a provision. */
private final String drGlCode;
private final String drGlName;
private final String incomeGlCode;
private final String incomeGlName;
private final String glTransactionId;
private final String voucherNo;
private final String postedOnDate;
}
@@ -0,0 +1,91 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/**
* Interest schedule of one holding: every half-yearly coupon and every 31 March / 30 September closing provision from
* purchase to maturity. Calculated, not yet posted.
*/
@Getter
@Builder
public class InvestmentInterestScheduleData {
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final BigDecimal faceValue;
private final BigDecimal unitValue;
private final BigDecimal balanceUnits;
private final BigDecimal purchaseRate;
private final BigDecimal bookValue;
private final BigDecimal intRate;
private final String dealDate;
private final String lastIntDate;
private final String maturityDate;
/**
* Broken-period interest paid to the seller at purchase — booked as an expense (GL 500606), not in this schedule.
*/
private final BigDecimal purchasedAccruedInterest;
private final BigDecimal couponPerHalfYear;
private final String asOnDate;
private final String interestGlCode;
private final String interestGlName;
/** Bank the coupon is received into by default: the purchase's paying bank. */
private final Long bankGlAccountId;
private final String bankGlCode;
private final String bankGlName;
private final String status;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
/** COUPON (cash received) or PROVISION (closing accrual). */
private final String type;
private final String intDate;
private final String fromDate;
private final String toDate;
private final int days;
/** Coupon received, or interest earned since the last coupon up to the closing. */
private final BigDecimal grossInterest;
/** Interest Receivable balance before this line (purchased accrued interest / earlier provision). */
private final BigDecimal alreadyBooked;
/** Provision to book now, or the part of the coupon that is new income. */
private final BigDecimal amount;
/** True when the date is on or before the as-on date. */
private final boolean due;
/** Posted to GL: its JV voucher and GL transaction. */
private final boolean posted;
private final String voucherNo;
private final String glTransactionId;
private final String postedOnDate;
/** The next line to post and its date has come. */
private final boolean postable;
/** Unposted and its date is within the reminder window (or past). */
private final boolean dueSoon;
}
}

Some files were not shown because too many files have changed in this diff Show More