Compare commits

..
Author SHA1 Message Date
anshul 140c76dac9 feat(investment): non-SLR rating, 10% limits and debt-only mutual funds
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 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Documentation build / build (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 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-23 12:40:43 +05:30
anshul 13ad567a8b feat(investment): bucket wise investment report by time left to maturity 2026-10-23 12:39:53 +05:30
anshul c3bb771d8a feat(investment): total interest received and receivable report 2026-10-23 11:26:09 +05:30
anshul 52ca2bfc03 Merge remote-tracking branch 'origin/dev-latest' into feature/investment
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract 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 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
# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml
2026-10-22 18:32:44 +05:30
anshul 3ef5a684ae feat(investment): due date diary and keep dropped deals so their numbers are not reused
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 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 - 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-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 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 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 - MariaDB / 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-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Documentation build / build (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 7 of 10) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
2026-10-22 17:34:41 +05:30
keshav 7d9c5563a4 KYC management: notice queue, stage-wise audit, RD base chart
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 7 of 10) (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 - PostgreSQL / test (test-core-1) (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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-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-20 17:13:12 +05:30
niteshandClaude Opus 5.5 43c60223af feat(fixed-deposits): account-engine FD quote at opening, FD Special schedule and locked Special terms
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
PROBLEM
- The account-opening screen quoted FD maturity with a stand-alone calculator
  (calculate-fd-interest) that counts posting periods from the deposit date.
  The account engine closes them at calendar period ends (from the financial-
  year start month), so the quote differed from the account once created,
  e.g. FD DOUBLE 546,348,939 @ 6.75% for 5 years from 2026-10-21, quarterly:
  quoted 763,660,633.77, account 763,697,613.00.
- FD Special (monthly interest payout) was quoted with compounding, and its
  product could be saved with Interest Calculation Type "Compounding".

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:49:14 +05:30
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
189 changed files with 14182 additions and 4192 deletions
@@ -95,6 +95,198 @@ public class CommandWrapperBuilder {
return this; 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() { public CommandWrapperBuilder createChequeStockLot() {
this.actionName = "CREATE"; this.actionName = "CREATE";
this.entityName = "CHEQUESTOCKLOT"; this.entityName = "CHEQUESTOCKLOT";
@@ -21,11 +21,7 @@ package org.apache.fineract.portfolio.aml.domain;
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */ /** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
public enum AmlAppliesTo { public enum AmlAppliesTo {
/** SAVINGS, LOAN, TRANSFER, ALL;
* CURRENT is a current / Cash Credit account. The engine is not yet fed current-account transactions, so a rule written
* for it is catalogued but does not fire until it is.
*/
SAVINGS, LOAN, TRANSFER, CURRENT, ALL;
public boolean matches(AmlTransactionType transactionType) { public boolean matches(AmlTransactionType transactionType) {
return this == ALL || this.name().equals(transactionType.name()); return this == ALL || this.name().equals(transactionType.name());
@@ -152,15 +152,6 @@ public class AmlTransactionRule {
return rule; return rule;
} }
/**
* Narrows a rule to one direction - CREDIT (money in) or DEBIT (money out), null for either - and, when asked, to cash
* only. The engine already honours both; a rule written without them applies to every credit and debit, cash or not.
*/
public void restrictTo(final String direction, final boolean cashOnly) {
this.direction = direction;
this.cashOnly = cashOnly;
}
public Long getId() { public Long getId() {
return id; return id;
} }
@@ -27,7 +27,6 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValue;
@Entity @Entity
@@ -119,16 +118,6 @@ public class ClientRiskRule {
@Column(name = "cc_relevant", nullable = false) @Column(name = "cc_relevant", nullable = false)
private boolean ccRelevant; private boolean ccRelevant;
/**
* A rule authored from the Rules Master starts on trial: it is listed first and marked new until an officer marks it
* normal. Every rule that was already in the catalog is normal.
*/
@Column(name = "on_trial", nullable = false)
private boolean onTrial;
@Column(name = "trial_started_on")
private LocalDate trialStartedOn;
public static final String KIND_KYC = "KYC"; public static final String KIND_KYC = "KYC";
public static final String KIND_AML = "AML"; public static final String KIND_AML = "AML";
@@ -138,30 +127,6 @@ public class ClientRiskRule {
return ccRelevant; return ccRelevant;
} }
public boolean isOnTrial() {
return onTrial;
}
public LocalDate getTrialStartedOn() {
return trialStartedOn;
}
/** Puts a newly authored rule on trial from {@code startedOn}. */
public void startTrial(final LocalDate startedOn) {
this.onTrial = true;
this.trialStartedOn = startedOn;
}
/** Ends the trial: the rule is a normal part of the catalog. */
public void markNormal() {
this.onTrial = false;
}
/** Marks the scenario as one that watches Cash Credit accounts, so the Rules Master CC chip lists it. */
public void markCcRelevant(final boolean ccRelevant) {
this.ccRelevant = ccRelevant;
}
/** /**
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category * Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
* are attached separately by the caller, which owns the CodeValue lookup. * are attached separately by the caller, which owns the CodeValue lookup.
@@ -42,8 +42,4 @@ public interface CurrentAccountReadPlatformService {
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName, List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate); String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate);
/** As above; with {@code overdraftOnly}, only the Cash Credit accounts - those that allow an overdraft. */
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate, boolean overdraftOnly);
} }
+2
View File
@@ -81,6 +81,8 @@ dependencies {
'org.apache.avro:avro', 'org.apache.avro:avro',
'jakarta.ws.rs:jakarta.ws.rs-api', 'jakarta.ws.rs:jakarta.ws.rs-api',
'org.glassfish.jersey.media:jersey-media-multipart', 'org.glassfish.jersey.media:jersey-media-multipart',
// Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one.
'org.glassfish.jersey.media:jersey-media-sse',
'org.springframework:spring-jms', 'org.springframework:spring-jms',
'org.springframework:spring-context-support', 'org.springframework:spring-context-support',
@@ -1570,9 +1570,76 @@ public class AccountingProcessorHelper {
return mapping == null ? null : mapping.getGlAccount(); return mapping == null ? null : mapping.getGlAccount();
} }
/**
* The clearing account for the savings-to-savings account transfer running on this thread, taken from a product's
* own Transfers In Suspense mapping (see {@link #useProductTransferSuspense}). Null outside such a transfer.
*/
private static final ThreadLocal<GLAccount> TRANSFER_CLEARING_ACCOUNT = new ThreadLocal<>();
/**
* Makes both legs of a savings-to-savings account transfer (savings to RD by auto debit, savings to savings, a
* deposit paying out) clear through the Transfers In Suspense account set on the product's Accounting tab, rather
* than the organisation-wide Liability Transfer financial activity.
* <p>
* The receiving product's mapping is used first, then the paying product's: a savings product screen does not
* offer Transfers In Suspense, so for savings to RD it is the RD product's mapping. Both legs use the same account,
* so it nets to nil. Where neither product has one, nothing is set and the transfer clears through Liability
* Transfer as before.
* <p>
* Must be paired with {@link #clearProductTransferSuspense()} in a finally block.
*
* @return true when a product mapping was found and is now in use
*/
public boolean useProductTransferSuspense(final Long toSavingsProductId, final Long fromSavingsProductId) {
final int transfersSuspense = CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue();
GLAccount account = optionalLinkedGLAccountForSavingsProduct(toSavingsProductId, transfersSuspense, null);
if (account == null) {
account = optionalLinkedGLAccountForSavingsProduct(fromSavingsProductId, transfersSuspense, null);
}
if (account == null) {
TRANSFER_CLEARING_ACCOUNT.remove();
return false;
}
TRANSFER_CLEARING_ACCOUNT.set(account);
return true;
}
public void clearProductTransferSuspense() {
TRANSFER_CLEARING_ACCOUNT.remove();
}
/**
* For undoing one leg of an account transfer: reverses through the clearing account that leg was actually posted
* to, whether a product's Transfers In Suspense or the Liability Transfer financial activity. A reversal works the
* accounts out again rather than copying the original rows, so without this a transfer posted to a product's
* suspense account would be reversed against Liability Transfer and leave both accounts open.
* <p>
* The clearing row is the credit of a withdrawal and the debit of a deposit (the largest, should a fee sit beside
* it). Nothing is set where the leg has no journal entries. Must be paired with
* {@link #clearProductTransferSuspense()} in a finally block.
*/
public void useClearingAccountPostedFor(final Long savingsTransactionId, final boolean isWithdrawal) {
TRANSFER_CLEARING_ACCOUNT.remove();
if (savingsTransactionId == null) {
return;
}
final List<Long> found = jdbcTemplate.queryForList("""
SELECT account_id FROM acc_gl_journal_entry
WHERE savings_transaction_id = ? AND type_enum = ? AND reversed = false
ORDER BY amount DESC, id
LIMIT 1
""", Long.class, savingsTransactionId, isWithdrawal ? JournalEntryType.CREDIT.getValue() : JournalEntryType.DEBIT.getValue());
if (!found.isEmpty()) {
glAccountRepository.findById(found.get(0)).ifPresent(TRANSFER_CLEARING_ACCOUNT::set);
}
}
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
final Long paymentTypeId) { final Long paymentTypeId) {
GLAccount glAccount; GLAccount glAccount;
if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) {
return TRANSFER_CLEARING_ACCOUNT.get();
}
if (isOrganizationAccount(accountMappingTypeId)) { if (isOrganizationAccount(accountMappingTypeId)) {
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
@@ -92,7 +92,7 @@ public class TenantMenuAccessApiResource {
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES", "MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING", "MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT", "MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT", "MENU_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}. * This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
@@ -144,14 +144,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer, final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance); isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt,
transactionDate, transactionAmount, paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName); final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill); final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, // As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped.
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); final SavingsAccountTransaction withdrawal;
final SavingsAccountTransaction deposit;
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
try {
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, transactionDate, transactionAmount,
paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, transactionAmount,
paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
applyToRecurringDepositSchedule(toSavingsAccount, deposit); applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) { if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
@@ -277,14 +285,27 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
if (accountTransfer.getToLoanTransaction() != null) { if (accountTransfer.getToLoanTransaction() != null) {
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction()); this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
} }
// Each leg is reversed through the clearing account it was posted to (a product's Transfers In Suspense or
// Liability Transfer), so the reversal clears that same account.
if (accountTransfer.getFromTransaction() != null) { if (accountTransfer.getFromTransaction() != null) {
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true);
try {
this.savingsAccountWritePlatformService.undoTransaction( this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(), accountTransfer.accountTransferDetails().fromSavingsAccount().getId(),
true); accountTransfer.getFromTransaction().getId(), true);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
} }
if (accountTransfer.getToSavingsTransaction() != null) { if (accountTransfer.getToSavingsTransaction() != null) {
this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(), this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false);
try {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
accountTransfer.getToSavingsTransaction().getId(), true); accountTransfer.getToSavingsTransaction().getId(), true);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
} }
accountTransfer.reverse(); accountTransfer.reverse();
this.accountTransferRepository.save(accountTransfer); this.accountTransferRepository.save(accountTransfer);
@@ -407,13 +428,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
transactionDate = transactionDate.plusDays(1); transactionDate = transactionDate.plusDays(1);
} }
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, // Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(), // product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping.
accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill); final SavingsAccountTransaction withdrawal;
final SavingsAccountTransaction deposit;
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
try {
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, accountTransferDTO.getFmt(),
transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
transactionBooleanValues, backdatedTxnsAllowedTill);
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer,
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); isRegularTransaction, backdatedTxnsAllowedTill);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
applyToRecurringDepositSchedule(toSavingsAccount, deposit); applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount, accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
@@ -22,24 +22,17 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TreeMap;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
@@ -48,19 +41,13 @@ import org.springframework.stereotype.Component;
/** /**
* How much came into, and went out of, each Cash Credit account over a window, set against what the customer declared * How much came into, and went out of, each Cash Credit account over a window, set against what the customer declared
* and what the bank sanctioned - and, for one account, where it came from and went, by what mode, what that adds up to, * and what the bank sanctioned.
* and which of the bank's CC rules it hits and which it does not.
* *
* <p> * <p>
* Read straight from the posted current-account transactions, so it works today without the AML engine being fed those * Read straight from the posted current-account transactions, so it works today without the AML engine being fed those
* transactions. A Cash Credit account is a current account allowed to draw an overdraft; only those are listed. Accounts are * transactions. A Cash Credit account is a current account allowed to draw an overdraft; every active current account
* limited to the caller's branch hierarchy, and the busiest are listed first. * is listed, with that flag, so a plain current account is not hidden from view. Accounts are limited to the caller's
* * branch hierarchy, and the busiest are listed first.
* <p>
* The rule checks apply each CC rule's own logic to the account's own transactions here, the way the engine would if it were
* fed them. A rule that turns on an officer's judgement, or that the system does not yet check, is shown as not checked
* rather than as passed. The observations and the fixed values behind the CC scenarios are indicative starting values, not
* findings; the bank's compliance team sets the real thresholds.
*/ */
@Path("/v1/aml/cc-tracking") @Path("/v1/aml/cc-tracking")
@Component @Component
@@ -68,30 +55,6 @@ import org.springframework.stereotype.Component;
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public class AmlCcTrackingApiResource { public class AmlCcTrackingApiResource {
// ── Starting values for the observations and the CC scenarios (indicative; to be set by the bank) ─────────────────
/** Credits and debits this many times the declared turnover (or the sanctioned limit) are flagged. */
private static final BigDecimal TURNOVER_MULTIPLE = BigDecimal.valueOf(3);
/** A credit is "rapid in-out" when this share of it is paid out again within {@link #RAPID_DAYS} days. */
private static final BigDecimal RAPID_SHARE = new BigDecimal("0.80");
private static final int RAPID_DAYS = 3;
/** One party handling this share of all the money moved is a concentration. */
private static final BigDecimal CONCENTRATION_SHARE = new BigDecimal("0.50");
/** Cash making up this share of credits or debits is a high cash share. */
private static final BigDecimal CASH_SHARE = new BigDecimal("0.50");
/** Peak use of the sanctioned limit worth noting, and the days within which a return to credit counts as "cleared". */
private static final BigDecimal LIMIT_USE = new BigDecimal("0.80");
private static final int CLEARED_DAYS = 7;
/** A party both paying in and being paid, the smaller side at least this share of the larger. */
private static final BigDecimal TWO_WAY_SHARE = new BigDecimal("0.50");
/** Money with no counterparty recorded, as a share of everything moved. */
private static final BigDecimal UNRECORDED_SHARE = new BigDecimal("0.50");
/** The window's turnover this many times the account's own earlier average is a spike. */
private static final BigDecimal SPIKE_MULTIPLE = BigDecimal.valueOf(3);
private static final String HIT = "HIT";
private static final String NO_HIT = "NO_HIT";
private static final String NOT_CHECKED = "NOT_CHECKED";
/** Current-account transaction types 1 and 2 are a deposit and a withdrawal, as on a savings account. */ /** Current-account transaction types 1 and 2 are a deposit and a withdrawal, as on a savings account. */
private static final String SQL = """ private static final String SQL = """
SELECT ca.id AS account_id, ca.account_no, c.id AS client_id, c.display_name AS client_name, SELECT ca.id AS account_id, ca.account_no, c.id AS client_id, c.display_name AS client_name,
@@ -112,40 +75,16 @@ public class AmlCcTrackingApiResource {
AND t.transaction_type_enum IN (1, 2) AND t.transaction_date > ? AND t.transaction_date <= ? AND t.transaction_type_enum IN (1, 2) AND t.transaction_date > ? AND t.transaction_date <= ?
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE ca.status_enum = 300 AND ca.allow_overdraft = true WHERE ca.status_enum = 300
AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?) AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
GROUP BY ca.id, c.id, o.id, k.id GROUP BY ca.id, c.id, o.id, k.id
ORDER BY SUM(t.amount) DESC NULLS LAST, ca.account_no ORDER BY SUM(t.amount) DESC NULLS LAST, ca.account_no
"""; """;
private static final String TRAIL_SELECT = """
SELECT t.id, t.current_account_id, t.transaction_date, t.transaction_type_enum, t.amount, t.ref_no, t.remarks,
t.running_balance_derived, pt.value AS payment_type, COALESCE(pt.is_cash_payment, false) AS is_cash,
pd.account_number AS cp_account, pd.bank_number AS cp_bank, pd.routing_code AS cp_ifsc, pd.check_number AS cheque_no
FROM m_current_account_transaction t
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE t.is_reversed = false AND t.transaction_type_enum IN (1, 2)
AND t.transaction_date > ? AND t.transaction_date <= ?
""";
/** The CC rules: every AML scenario marked as watching a CC account, with the live-transaction rule behind it, if any. */
private static final String RULES_SQL = """
SELECT r.id, r.rule_name, r.risk_category, r.coverage_status, r.on_trial,
t.rule_type, t.threshold_amount, t.min_limit_amount, t.max_limit_amount, t.window_days, t.direction,
t.cash_only, t.min_transaction_count, t.subject_scope, t.is_active
FROM m_client_risk_rule r
LEFT JOIN m_aml_transaction_rule t ON t.source_client_risk_rule_id = r.id
WHERE r.cc_relevant = true AND r.rule_kind = 'AML'
ORDER BY r.on_trial DESC, r.id
""";
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create(); private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── The list ─────────────────────────────────────────────────────────────────────────────────────────────────────
@GET @GET
public String retrieve(@QueryParam("days") @DefaultValue("30") final int days) { public String retrieve(@QueryParam("days") @DefaultValue("30") final int days) {
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy(); final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
@@ -153,28 +92,22 @@ public class AmlCcTrackingApiResource {
final LocalDate asOf = DateUtils.getBusinessLocalDate(); final LocalDate asOf = DateUtils.getBusinessLocalDate();
final LocalDate from = asOf.minusDays(window); final LocalDate from = asOf.minusDays(window);
final List<Map<String, Object>> accountRows = this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from),
java.sql.Date.valueOf(asOf), hierarchy + "%");
// The transactions and earlier history of all the accounts at once, to check each against the CC rules
final Map<Long, List<Txn>> txnsByAccount = loadTxns(from, asOf, null);
final Map<Long, BigDecimal> priorVolume = priorVolumes(from, window);
final List<CcRule> rules = loadRules();
final List<Map<String, Object>> rows = new ArrayList<>(); final List<Map<String, Object>> rows = new ArrayList<>();
int rank = 0; int rank = 0;
for (final Map<String, Object> r : accountRows) { for (final Map<String, Object> r : this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from),
final long accountId = ((Number) r.get("account_id")).longValue(); java.sql.Date.valueOf(asOf), hierarchy + "%")) {
final BigDecimal credits = money(r.get("credits")); final BigDecimal credits = money(r.get("credits"));
final BigDecimal debits = money(r.get("debits")); final BigDecimal debits = money(r.get("debits"));
final BigDecimal limit = money(r.get("overdraft_limit")); final BigDecimal limit = money(r.get("overdraft_limit"));
final BigDecimal balance = money(r.get("account_balance_derived")); final BigDecimal balance = money(r.get("account_balance_derived"));
final BigDecimal declaredMonthly = r.get("declared_monthly_turnover") == null ? null : money(r.get("declared_monthly_turnover")); final BigDecimal declaredMonthly = r.get("declared_monthly_turnover") == null ? null : money(r.get("declared_monthly_turnover"));
final BigDecimal declaredForWindow = scaledToWindow(declaredMonthly, window); // What the declared monthly turnover comes to over this window, so a 7-day look is not judged against a month.
final BigDecimal declaredForWindow = declaredMonthly == null ? null
: declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP);
final Map<String, Object> row = new LinkedHashMap<>(); final Map<String, Object> row = new LinkedHashMap<>();
row.put("rank", ++rank); row.put("rank", ++rank);
row.put("accountId", accountId); row.put("accountId", r.get("account_id"));
row.put("accountNo", r.get("account_no")); row.put("accountNo", r.get("account_no"));
row.put("clientId", r.get("client_id")); row.put("clientId", r.get("client_id"));
row.put("clientName", r.get("client_name")); row.put("clientName", r.get("client_name"));
@@ -182,6 +115,7 @@ public class AmlCcTrackingApiResource {
row.put("isCc", Boolean.TRUE.equals(r.get("allow_overdraft"))); row.put("isCc", Boolean.TRUE.equals(r.get("allow_overdraft")));
row.put("sanctionedLimit", limit); row.put("sanctionedLimit", limit);
row.put("balance", balance); row.put("balance", balance);
// How much of the sanctioned limit is drawn: the overdrawn part of the balance over the limit.
row.put("limitDrawnPercent", limit.signum() > 0 && balance.signum() < 0 row.put("limitDrawnPercent", limit.signum() > 0 && balance.signum() < 0
? balance.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null); ? balance.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
row.put("declaredMonthlyTurnover", declaredMonthly); row.put("declaredMonthlyTurnover", declaredMonthly);
@@ -194,28 +128,10 @@ public class AmlCcTrackingApiResource {
row.put("cashDebits", money(r.get("cash_debits"))); row.put("cashDebits", money(r.get("cash_debits")));
row.put("cashCreditPercent", credits.signum() > 0 row.put("cashCreditPercent", credits.signum() > 0
? money(r.get("cash_credits")).multiply(BigDecimal.valueOf(100)).divide(credits, 1, RoundingMode.HALF_UP) : null); ? money(r.get("cash_credits")).multiply(BigDecimal.valueOf(100)).divide(credits, 1, RoundingMode.HALF_UP) : null);
// Credits and debits as a multiple of what the customer said the business turns over in this window.
row.put("creditsVsDeclared", ratio(credits, declaredForWindow)); row.put("creditsVsDeclared", ratio(credits, declaredForWindow));
row.put("debitsVsDeclared", ratio(debits, declaredForWindow)); row.put("debitsVsDeclared", ratio(debits, declaredForWindow));
row.put("lastTxnDate", r.get("last_txn_date") == null ? null : r.get("last_txn_date").toString()); row.put("lastTxnDate", r.get("last_txn_date") == null ? null : r.get("last_txn_date").toString());
final List<Map<String, Object>> checks = checkRules(rules, txnsByAccount.getOrDefault(accountId, List.of()), limit,
declaredForWindow, priorVolume.get(accountId), window);
int hit = 0;
int noHit = 0;
int notChecked = 0;
for (final Map<String, Object> c : checks) {
final String status = (String) c.get("status");
if (HIT.equals(status)) {
hit++;
} else if (NO_HIT.equals(status)) {
noHit++;
} else {
notChecked++;
}
}
row.put("rulesHit", hit);
row.put("rulesNoHit", noHit);
row.put("rulesNotChecked", notChecked);
rows.add(row); rows.add(row);
} }
@@ -227,791 +143,6 @@ public class AmlCcTrackingApiResource {
return this.gson.toJson(out); return this.gson.toJson(out);
} }
// ── One account ──────────────────────────────────────────────────────────────────────────────────────────────────
/**
* Every credit and debit on one CC account in the window, with the mode and the party on the other side where the
* payment recorded them, totalled by mode and by party, an analysis of what it adds up to, and the CC rules checked against
* it. The account's own branch must be at or below the caller's.
*/
@GET
@Path("{accountId}/trail")
public String trail(@PathParam("accountId") final Long accountId, @QueryParam("days") @DefaultValue("30") final int days) {
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
final List<Map<String, Object>> account = this.jdbcTemplate.queryForList("""
SELECT ca.id, ca.account_no, c.display_name AS client_name, o.name AS branch_name, ca.overdraft_limit,
ca.account_balance_derived, ca.allow_overdraft,
COALESCE(NULLIF(c.monthly_turnover, 0), NULLIF(k.declared_annual_turnover, 0) / 12) AS declared_monthly_turnover
FROM m_current_account ca
LEFT JOIN m_client c ON c.id = ca.client_id
LEFT JOIN m_client_kyc k ON k.client_id = c.id
LEFT JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id)
WHERE ca.id = ? AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
""", accountId, hierarchy + "%");
if (account.isEmpty()) {
throw new NotFoundException("Account not found: " + accountId);
}
final int window = Math.min(Math.max(days, 1), 366);
final LocalDate asOf = DateUtils.getBusinessLocalDate();
final LocalDate from = asOf.minusDays(window);
final List<Txn> txns = loadTxns(from, asOf, accountId).getOrDefault(accountId, List.of());
final Map<String, Object> a = account.get(0);
final BigDecimal limit = money(a.get("overdraft_limit"));
final BigDecimal balance = money(a.get("account_balance_derived"));
final BigDecimal declaredMonthly = a.get("declared_monthly_turnover") == null ? null : money(a.get("declared_monthly_turnover"));
final BigDecimal declaredForWindow = scaledToWindow(declaredMonthly, window);
// Newest first for the list, oldest first for the analysis
final List<Map<String, Object>> txnRows = new ArrayList<>();
for (int i = txns.size() - 1; i >= 0; i--) {
final Txn t = txns.get(i);
final Map<String, Object> row = new LinkedHashMap<>();
row.put("id", t.id);
row.put("date", t.date.toString());
row.put("direction", t.credit ? "IN" : "OUT");
row.put("amount", t.amount);
row.put("mode", t.mode);
row.put("isCash", t.cash);
row.put("reference", t.reference);
row.put("chequeNo", t.chequeNo);
row.put("counterpartyAccount", t.partyAccount);
row.put("counterpartyBank", t.partyBank);
row.put("counterpartyIfsc", t.partyIfsc);
row.put("remarks", t.remarks);
row.put("balanceAfter", t.balanceAfter);
txnRows.add(row);
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("accountNo", a.get("account_no"));
out.put("clientName", a.get("client_name"));
out.put("branchName", a.get("branch_name"));
out.put("isCc", Boolean.TRUE.equals(a.get("allow_overdraft")));
out.put("sanctionedLimit", limit);
out.put("balance", balance);
out.put("declaredMonthlyTurnover", declaredMonthly);
out.put("from", from.toString());
out.put("asOf", asOf.toString());
out.put("days", window);
out.put("transactions", txnRows);
out.putAll(analyse(txns, limit, declaredForWindow));
out.put("ruleChecks", checkRules(loadRules(), txns, limit, declaredForWindow, priorVolumes(from, window).get(accountId), window));
return this.gson.toJson(out);
}
// ── Loading ──────────────────────────────────────────────────────────────────────────────────────────────────────
/** The window's transactions, oldest first, by account - for one account, or for every current account. */
private Map<Long, List<Txn>> loadTxns(final LocalDate from, final LocalDate asOf, final Long accountId) {
final String sql = TRAIL_SELECT + (accountId == null ? "" : " AND t.current_account_id = ?")
+ " ORDER BY t.transaction_date ASC, t.id ASC";
final List<Map<String, Object>> rows = accountId == null
? this.jdbcTemplate.queryForList(sql, java.sql.Date.valueOf(from), java.sql.Date.valueOf(asOf))
: this.jdbcTemplate.queryForList(sql, java.sql.Date.valueOf(from), java.sql.Date.valueOf(asOf), accountId);
final Map<Long, List<Txn>> byAccount = new HashMap<>();
for (final Map<String, Object> r : rows) {
final Txn t = new Txn();
t.id = ((Number) r.get("id")).longValue();
t.date = ((java.sql.Date) r.get("transaction_date")).toLocalDate();
t.credit = ((Number) r.get("transaction_type_enum")).intValue() == 1;
t.amount = money(r.get("amount"));
t.reference = (String) r.get("ref_no");
t.remarks = (String) r.get("remarks");
t.balanceAfter = r.get("running_balance_derived") == null ? null : money(r.get("running_balance_derived"));
t.cash = Boolean.TRUE.equals(r.get("is_cash"));
t.chequeNo = (String) r.get("cheque_no");
t.partyAccount = (String) r.get("cp_account");
t.partyBank = (String) r.get("cp_bank");
t.partyIfsc = (String) r.get("cp_ifsc");
t.mode = modeOf(t.cash, (String) r.get("payment_type"), t.chequeNo, t.remarks);
t.party = partyOf(t);
byAccount.computeIfAbsent(((Number) r.get("current_account_id")).longValue(), k -> new ArrayList<>()).add(t);
}
return byAccount;
}
/** The average turnover (credits plus debits) of each account over the three windows before this one, where there is any. */
private Map<Long, BigDecimal> priorVolumes(final LocalDate from, final int window) {
final Map<Long, BigDecimal> result = new HashMap<>();
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT t.current_account_id, SUM(t.amount) AS total
FROM m_current_account_transaction t
WHERE t.is_reversed = false AND t.transaction_type_enum IN (1, 2)
AND t.transaction_date > ? AND t.transaction_date <= ?
GROUP BY t.current_account_id
""", java.sql.Date.valueOf(from.minusDays(3L * window)), java.sql.Date.valueOf(from));
for (final Map<String, Object> r : rows) {
result.put(((Number) r.get("current_account_id")).longValue(),
money(r.get("total")).divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP));
}
return result;
}
// ── The CC rules ─────────────────────────────────────────────────────────────────────────────────────────────────
/** One CC rule: the catalog entry and, where there is one, the live-transaction rule behind it. */
private static final class CcRule {
long id;
String name;
int riskCategory;
String coverage;
boolean onTrial;
String ruleType;
BigDecimal threshold;
BigDecimal min;
BigDecimal max;
Integer windowDays;
String direction;
boolean cashOnly;
Integer minCount;
String subjectScope;
boolean active;
}
private List<CcRule> loadRules() {
final List<CcRule> rules = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(RULES_SQL)) {
final CcRule rule = new CcRule();
rule.id = ((Number) r.get("id")).longValue();
rule.name = (String) r.get("rule_name");
rule.riskCategory = r.get("risk_category") == null ? 0 : ((Number) r.get("risk_category")).intValue();
rule.coverage = (String) r.get("coverage_status");
rule.onTrial = Boolean.TRUE.equals(r.get("on_trial"));
rule.ruleType = (String) r.get("rule_type");
rule.threshold = r.get("threshold_amount") == null ? null : money(r.get("threshold_amount"));
rule.min = r.get("min_limit_amount") == null ? null : money(r.get("min_limit_amount"));
rule.max = r.get("max_limit_amount") == null ? null : money(r.get("max_limit_amount"));
rule.windowDays = r.get("window_days") == null ? null : ((Number) r.get("window_days")).intValue();
rule.direction = (String) r.get("direction");
rule.cashOnly = Boolean.TRUE.equals(r.get("cash_only"));
rule.minCount = r.get("min_transaction_count") == null ? null : ((Number) r.get("min_transaction_count")).intValue();
rule.subjectScope = (String) r.get("subject_scope");
rule.active = r.get("is_active") == null || Boolean.TRUE.equals(r.get("is_active"));
rules.add(rule);
}
return rules;
}
/**
* Each CC rule against one account's transactions. A rule with a live-transaction rule behind it is checked the way the
* engine would check it; a CC scenario is checked by its own test; a rule that turns on an officer's judgement, or that the
* system does not yet check, is NOT_CHECKED - never reported as passed.
*/
private List<Map<String, Object>> checkRules(final List<CcRule> rules, final List<Txn> txns, final BigDecimal limit,
final BigDecimal declaredForWindow, final BigDecimal priorAverageVolume, final int window) {
final Facts f = facts(txns, limit);
final List<Map<String, Object>> out = new ArrayList<>();
for (final CcRule rule : rules) {
final String[] result;
final String kind;
if (rule.name != null && rule.name.startsWith("CC - ")) {
kind = "CC scenario";
result = scenario(rule, f, txns, limit, declaredForWindow, priorAverageVolume);
} else if (rule.ruleType != null) {
kind = "Transaction rule";
result = transactionRule(rule, txns, window);
} else if ("MANUAL_REVIEW".equals(rule.coverage)) {
kind = "Manual review";
result = new String[] { NOT_CHECKED, "Turns on an officer's judgement, so the system does not check it." };
} else {
kind = "Planned";
result = new String[] { NOT_CHECKED, "Planned: the system does not check this yet." };
}
final Map<String, Object> m = new LinkedHashMap<>();
m.put("ruleId", rule.id);
m.put("name", rule.name);
m.put("kind", kind);
m.put("riskCategory", rule.riskCategory);
m.put("onTrial", rule.onTrial);
m.put("status", result[0]);
m.put("detail", result[1]);
out.add(m);
}
return out;
}
/** A live-transaction rule applied to this account's transactions, with the engine's own meaning of each rule type. */
private String[] transactionRule(final CcRule rule, final List<Txn> txns, final int tabWindow) {
if (!rule.active) {
return new String[] { NOT_CHECKED, "The rule is switched off." };
}
if (rule.subjectScope != null) {
return new String[] { NOT_CHECKED, "Applies to a kind of customer (" + rule.subjectScope + "), not to the transactions." };
}
if (rule.threshold == null) {
return new String[] { NOT_CHECKED, "The rule has no amount, so there is nothing to compare a transaction with." };
}
final List<Txn> pool = new ArrayList<>();
for (final Txn t : txns) {
if (rule.direction != null && "CREDIT".equalsIgnoreCase(rule.direction) != t.credit) {
continue;
}
if (rule.cashOnly && !t.cash) {
continue;
}
pool.add(t);
}
final String scope = (rule.direction == null ? "credit or debit" : rule.direction.toLowerCase()) + (rule.cashOnly ? " cash" : "")
+ " transaction";
if ("THRESHOLD".equals(rule.ruleType)) {
Txn largest = null;
int count = 0;
for (final Txn t : pool) {
if (t.amount.compareTo(rule.threshold) >= 0) {
count++;
}
if (largest == null || t.amount.compareTo(largest.amount) > 0) {
largest = t;
}
}
if (count > 0) {
return new String[] { HIT, count + " " + scope + "(s) of " + rupees(rule.threshold) + " or more; the largest is "
+ rupees(largest.amount) + " on " + largest.date + "." };
}
return new String[] { NO_HIT, largest == null ? "There is no " + scope + " in the window."
: "No " + scope + " reaches " + rupees(rule.threshold) + "; the largest is " + rupees(largest.amount) + "." };
}
// STRUCTURING and VELOCITY: enough transactions, each inside the rule's band, adding up to the amount, within its window
final int days = rule.windowDays != null && rule.windowDays > 0 ? rule.windowDays : tabWindow;
final List<Txn> inBand = new ArrayList<>();
for (final Txn t : pool) {
if ((rule.min == null || t.amount.compareTo(rule.min) >= 0) && (rule.max == null || t.amount.compareTo(rule.max) <= 0)) {
inBand.add(t);
}
}
final int needed = rule.minCount == null || rule.minCount <= 1 ? 1 : rule.minCount;
int bestCount = 0;
BigDecimal bestSum = BigDecimal.ZERO;
for (final Txn end : inBand) {
int count = 0;
BigDecimal sum = BigDecimal.ZERO;
for (final Txn t : inBand) {
if (!t.date.isAfter(end.date) && t.date.isAfter(end.date.minusDays(days))) {
count++;
sum = sum.add(t.amount);
}
}
if (count >= needed && sum.compareTo(rule.threshold) >= 0) {
return new String[] { HIT, count + " " + scope + "(s) within " + days + " days add up to " + rupees(sum)
+ " (the rule fires at " + rupees(rule.threshold) + " from " + needed + " or more)." };
}
if (sum.compareTo(bestSum) > 0) {
bestSum = sum;
bestCount = count;
}
}
return new String[] { NO_HIT, inBand.isEmpty() ? "No " + scope + " inside the rule's band in the window."
: "At most " + bestCount + " " + scope + "(s) within " + days + " days, adding up to " + rupees(bestSum)
+ "; the rule needs " + needed + " or more adding up to " + rupees(rule.threshold) + "." };
}
/** The CC scenarios written for this tab, each by its own test. */
private String[] scenario(final CcRule rule, final Facts f, final List<Txn> txns, final BigDecimal limit,
final BigDecimal declaredForWindow, final BigDecimal priorAverageVolume) {
final String n = rule.name;
if (txns.isEmpty()) {
return new String[] { NO_HIT, "There is no activity on the account in the window." };
}
if (n.contains("credit turnover exceeds")) {
return turnover(f.in, "Credits (Cr)", declaredForWindow, limit);
}
if (n.contains("debit turnover exceeds")) {
return turnover(f.out, "Debits (Dr)", declaredForWindow, limit);
}
if (n.contains("substantially withdrawn within")) {
return f.rapidCount > 0
? new String[] { HIT, f.rapidCount + " credit(s) worth " + rupees(f.rapidValue) + " were mostly paid out within "
+ RAPID_DAYS + " days." }
: new String[] { NO_HIT, "No credit was mostly paid out again within " + RAPID_DAYS + " days." };
}
if (n.contains("Sudden spike")) {
if (priorAverageVolume == null || priorAverageVolume.signum() <= 0) {
return new String[] { NOT_CHECKED, "There is no earlier activity to compare this window with." };
}
final BigDecimal now = f.in.add(f.out);
final BigDecimal multiple = now.divide(priorAverageVolume, 2, RoundingMode.HALF_UP);
final String text = "This window moved " + rupees(now) + ", " + multiple + "x the account's own earlier average of "
+ rupees(priorAverageVolume) + ".";
return multiple.compareTo(SPIKE_MULTIPLE) >= 0 ? new String[] { HIT, text } : new String[] { NO_HIT, text };
}
if (n.contains("share of cash")) {
final BigDecimal inShare = f.in.signum() > 0 ? f.cashIn.divide(f.in, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
final BigDecimal outShare = f.out.signum() > 0 ? f.cashOut.divide(f.out, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
final String text = "Cash is " + percent(inShare) + " of credits and " + percent(outShare) + " of debits.";
return inShare.compareTo(CASH_SHARE) >= 0 || outShare.compareTo(CASH_SHARE) >= 0 ? new String[] { HIT, text }
: new String[] { NO_HIT, text };
}
if (n.contains("round tripping")) {
if (limit.signum() <= 0) {
return new String[] { NOT_CHECKED, "The account has no sanctioned limit." };
}
if (f.peakUse.compareTo(LIMIT_USE) >= 0 && f.clearedAfterPeak) {
return new String[] { HIT, "The limit was drawn to " + percent(f.peakUse) + " and the account was back in credit within "
+ CLEARED_DAYS + " days." };
}
return new String[] { NO_HIT, "The limit was drawn to at most " + percent(f.peakUse)
+ (f.peakUse.compareTo(LIMIT_USE) >= 0 ? ", but the account did not return to credit within " + CLEARED_DAYS + " days." : ".") };
}
if (n.contains("counterparty concentration")) {
if (f.top == null) {
return new String[] { NOT_CHECKED, "No transaction recorded the party on the other side." };
}
final String text = f.top.label + " handled " + percent(f.topShare) + " of the money moved.";
return f.topShare.compareTo(CONCENTRATION_SHARE) >= 0 && f.top.count >= 3 ? new String[] { HIT, text }
: new String[] { NO_HIT, text };
}
if (n.contains("Circular movement")) {
if (f.twoWay.isEmpty()) {
return new String[] { NO_HIT, "No party both paid in and was paid a similar amount." };
}
final Total first = f.twoWay.get(0);
return new String[] { HIT, first.label + " is on both sides: " + rupees(first.in) + " Cr and " + rupees(first.out) + " Dr." };
}
return new String[] { NOT_CHECKED, "Needs the customer's relationship data, which the system does not hold yet." };
}
private String[] turnover(final BigDecimal amount, final String label, final BigDecimal declaredForWindow, final BigDecimal limit) {
final BigDecimal vsDeclared = ratio(amount, declaredForWindow);
final BigDecimal vsLimit = ratio(amount, limit);
if (vsDeclared == null && vsLimit == null) {
return new String[] { NOT_CHECKED, "The customer has declared no turnover and the account has no sanctioned limit." };
}
final boolean hit = (vsDeclared != null && vsDeclared.compareTo(TURNOVER_MULTIPLE) >= 0)
|| (vsLimit != null && vsLimit.compareTo(TURNOVER_MULTIPLE) >= 0);
final String text = label + " of " + rupees(amount)
+ (vsDeclared != null ? " are " + vsDeclared + "x the declared turnover for the window" : "")
+ (vsLimit != null ? (vsDeclared != null ? " and " : " are ") + vsLimit + "x the sanctioned limit" : "") + ".";
return new String[] { hit ? HIT : NO_HIT, text };
}
// ── Analysis ─────────────────────────────────────────────────────────────────────────────────────────────────────
/** One posted credit or debit, as the analysis reads it. */
private static final class Txn {
long id;
LocalDate date;
boolean credit;
BigDecimal amount;
String reference;
String remarks;
BigDecimal balanceAfter;
boolean cash;
String chequeNo;
String partyAccount;
String partyBank;
String partyIfsc;
String mode;
String party;
}
/** Running in / out / count for one mode or party. */
private static final class Total {
String label;
String sub;
BigDecimal in = BigDecimal.ZERO;
BigDecimal out = BigDecimal.ZERO;
int count;
BigDecimal volume() {
return in.add(out);
}
boolean unrecorded() {
return sub == null && "Not recorded".equals(label);
}
}
/** The figures the observations and the CC scenarios are both judged on, worked out once. */
private static final class Facts {
BigDecimal in = BigDecimal.ZERO;
BigDecimal out = BigDecimal.ZERO;
BigDecimal cashIn = BigDecimal.ZERO;
BigDecimal cashOut = BigDecimal.ZERO;
BigDecimal volume = BigDecimal.ZERO;
BigDecimal lowest;
BigDecimal peakUse = BigDecimal.ZERO;
boolean clearedAfterPeak;
int rapidCount;
BigDecimal rapidValue = BigDecimal.ZERO;
Map<String, Total> byParty = new LinkedHashMap<>();
Total top;
BigDecimal topShare = BigDecimal.ZERO;
List<Total> twoWay = new ArrayList<>();
BigDecimal unrecorded = BigDecimal.ZERO;
}
private Facts facts(final List<Txn> txns, final BigDecimal limit) {
final Facts f = new Facts();
Txn lowestTxn = null;
for (final Txn t : txns) {
if (t.credit) {
f.in = f.in.add(t.amount);
if (t.cash) {
f.cashIn = f.cashIn.add(t.amount);
}
} else {
f.out = f.out.add(t.amount);
if (t.cash) {
f.cashOut = f.cashOut.add(t.amount);
}
}
if (t.balanceAfter != null && (f.lowest == null || t.balanceAfter.compareTo(f.lowest) < 0)) {
f.lowest = t.balanceAfter;
lowestTxn = t;
}
add(f.byParty, t.party, t.party, t.partyBank, t);
}
f.volume = f.in.add(f.out);
// How hard the limit was used, and whether the account was back in credit soon after its worst point
if (limit.signum() > 0 && f.lowest != null && f.lowest.signum() < 0) {
f.peakUse = f.lowest.negate().divide(limit, 4, RoundingMode.HALF_UP);
for (final Txn t : txns) {
if (lowestTxn != null && t.id != lowestTxn.id && t.balanceAfter != null && t.balanceAfter.signum() >= 0
&& !t.date.isBefore(lowestTxn.date) && !t.date.isAfter(lowestTxn.date.plusDays(CLEARED_DAYS))) {
f.clearedAfterPeak = true;
}
}
}
// Money in and straight out again. Each debit is set against one credit only - the earliest still unmatched that it
// falls within RAPID_DAYS of - so one payment out is not counted as the exit of several credits.
final List<Txn> credits = new ArrayList<>();
final Map<Long, BigDecimal> matched = new LinkedHashMap<>();
for (final Txn t : txns) {
if (t.credit) {
credits.add(t);
matched.put(t.id, BigDecimal.ZERO);
}
}
for (final Txn d : txns) {
if (d.credit) {
continue;
}
BigDecimal toPlace = d.amount;
for (final Txn c : credits) {
if (toPlace.signum() <= 0) {
break;
}
if (d.date.isBefore(c.date) || d.date.isAfter(c.date.plusDays(RAPID_DAYS))) {
continue;
}
final BigDecimal room = c.amount.subtract(matched.get(c.id));
if (room.signum() <= 0) {
continue;
}
final BigDecimal placed = room.min(toPlace);
matched.put(c.id, matched.get(c.id).add(placed));
toPlace = toPlace.subtract(placed);
}
}
for (final Txn c : credits) {
if (matched.get(c.id).compareTo(c.amount.multiply(RAPID_SHARE)) >= 0) {
f.rapidCount++;
f.rapidValue = f.rapidValue.add(c.amount);
}
}
// The party handling the most, the parties on both sides, and what has no party recorded
for (final Total t : f.byParty.values()) {
if (t.unrecorded()) {
f.unrecorded = f.unrecorded.add(t.volume());
continue;
}
if (f.top == null || t.volume().compareTo(f.top.volume()) > 0) {
f.top = t;
}
if (t.in.signum() > 0 && t.out.signum() > 0
&& t.in.min(t.out).divide(t.in.max(t.out), 4, RoundingMode.HALF_UP).compareTo(TWO_WAY_SHARE) >= 0) {
f.twoWay.add(t);
}
}
if (f.top != null && f.volume.signum() > 0) {
f.topShare = f.top.volume().divide(f.volume, 4, RoundingMode.HALF_UP);
}
return f;
}
private Map<String, Object> analyse(final List<Txn> txns, final BigDecimal limit, final BigDecimal declaredForWindow) {
final Facts f = facts(txns, limit);
int inCount = 0;
int outCount = 0;
Txn largestIn = null;
Txn largestOut = null;
BigDecimal highest = null;
int belowZero = 0;
final Map<String, Total> byMode = new LinkedHashMap<>();
final TreeMap<LocalDate, Total> byWeek = new TreeMap<>();
final java.util.Set<LocalDate> days = new java.util.HashSet<>();
for (final Txn t : txns) {
days.add(t.date);
if (t.credit) {
inCount++;
if (largestIn == null || t.amount.compareTo(largestIn.amount) > 0) {
largestIn = t;
}
} else {
outCount++;
if (largestOut == null || t.amount.compareTo(largestOut.amount) > 0) {
largestOut = t;
}
}
if (t.balanceAfter != null) {
highest = highest == null || t.balanceAfter.compareTo(highest) > 0 ? t.balanceAfter : highest;
if (t.balanceAfter.signum() < 0) {
belowZero++;
}
}
add(byMode, t.mode, t.mode, null, t);
final LocalDate weekStart = t.date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
add(byWeek, weekStart, weekStart.toString(), null, t);
}
final Map<String, Object> result = new LinkedHashMap<>();
final Map<String, Object> summary = new LinkedHashMap<>();
summary.put("in", f.in);
summary.put("out", f.out);
summary.put("net", f.in.subtract(f.out));
summary.put("inCount", inCount);
summary.put("outCount", outCount);
summary.put("averageIn", inCount == 0 ? null : f.in.divide(BigDecimal.valueOf(inCount), 2, RoundingMode.HALF_UP));
summary.put("averageOut", outCount == 0 ? null : f.out.divide(BigDecimal.valueOf(outCount), 2, RoundingMode.HALF_UP));
summary.put("largestIn", largest(largestIn));
summary.put("largestOut", largest(largestOut));
summary.put("activeDays", days.size());
summary.put("lowestBalance", f.lowest);
summary.put("highestBalance", highest);
summary.put("timesBelowZero", belowZero);
summary.put("peakLimitUsedPercent", limit.signum() > 0 && f.lowest != null && f.lowest.signum() < 0
? f.lowest.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
summary.put("cashIn", f.cashIn);
summary.put("cashOut", f.cashOut);
summary.put("creditsVsDeclared", ratio(f.in, declaredForWindow));
summary.put("debitsVsDeclared", ratio(f.out, declaredForWindow));
summary.put("declaredForWindow", declaredForWindow);
result.put("summary", summary);
result.put("byMode", totals(byMode, f.volume));
result.put("byCounterparty", totals(f.byParty, f.volume));
result.put("timeline", totals(byWeek, f.volume));
result.put("observations", observations(txns, f, limit, declaredForWindow));
return result;
}
private static <K> void add(final Map<K, Total> map, final K key, final String label, final String sub, final Txn t) {
final Total total = map.computeIfAbsent(key, k -> {
final Total fresh = new Total();
fresh.label = label;
fresh.sub = sub;
return fresh;
});
if (t.credit) {
total.in = total.in.add(t.amount);
} else {
total.out = total.out.add(t.amount);
}
total.count++;
}
private static <K> List<Map<String, Object>> totals(final Map<K, Total> map, final BigDecimal volume) {
final List<Total> list = new ArrayList<>(map.values());
// Modes and parties: biggest first. The timeline map is already in date order and keeps it.
if (!(map instanceof TreeMap)) {
list.sort(Comparator.comparing(Total::volume).reversed());
}
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Total t : list) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("label", t.label);
row.put("sub", t.sub);
row.put("in", t.in);
row.put("out", t.out);
row.put("count", t.count);
row.put("sharePercent", volume.signum() > 0
? t.volume().multiply(BigDecimal.valueOf(100)).divide(volume, 1, RoundingMode.HALF_UP) : null);
rows.add(row);
}
return rows;
}
private static Map<String, Object> largest(final Txn t) {
if (t == null) {
return null;
}
final Map<String, Object> m = new LinkedHashMap<>();
m.put("amount", t.amount);
m.put("date", t.date.toString());
m.put("party", t.party);
m.put("mode", t.mode);
return m;
}
/** What stands out, with the figures behind it. Starting values only - see the class note. */
private List<Map<String, Object>> observations(final List<Txn> txns, final Facts f, final BigDecimal limit,
final BigDecimal declaredForWindow) {
final List<Map<String, Object>> list = new ArrayList<>();
if (txns.isEmpty()) {
list.add(observation("INFO", "No activity", "There are no credits (Cr) or debits (Dr) on this account in the window."));
return list;
}
if (declaredForWindow == null) {
list.add(observation("INFO", "Declared turnover is missing",
"The customer has not declared a turnover, so the activity cannot be compared with the business profile."));
} else {
final BigDecimal inMultiple = ratio(f.in, declaredForWindow);
final BigDecimal outMultiple = ratio(f.out, declaredForWindow);
if (inMultiple != null && inMultiple.compareTo(TURNOVER_MULTIPLE) >= 0) {
list.add(observation("HIGH", "Credits far above the declared turnover", "Credits (Cr) of " + rupees(f.in) + " are "
+ inMultiple + "x the turnover declared for this window (" + rupees(declaredForWindow) + ")."));
}
if (outMultiple != null && outMultiple.compareTo(TURNOVER_MULTIPLE) >= 0) {
list.add(observation("HIGH", "Debits far above the declared turnover", "Debits (Dr) of " + rupees(f.out) + " are "
+ outMultiple + "x the turnover declared for this window (" + rupees(declaredForWindow) + ")."));
}
}
if (f.rapidCount > 0) {
list.add(observation("MEDIUM", "Money paid out soon after it came in", f.rapidCount + " credit(s) (Cr) worth "
+ rupees(f.rapidValue) + " were mostly (" + RAPID_SHARE.multiply(BigDecimal.valueOf(100)).intValue()
+ "% or more) paid out again within " + RAPID_DAYS + " days."));
}
if (f.top != null && f.topShare.compareTo(CONCENTRATION_SHARE) >= 0) {
list.add(observation("MEDIUM", "One party handles most of the money", f.top.label
+ (f.top.sub == null ? "" : " (" + f.top.sub + ")") + " accounts for " + percent(f.topShare) + " of everything moved ("
+ rupees(f.top.in) + " Cr, " + rupees(f.top.out) + " Dr)."));
}
for (final Total t : f.twoWay) {
list.add(observation("HIGH", "Same party on both sides", t.label + " is on both sides: credits (Cr) of " + rupees(t.in)
+ " came from it and debits (Dr) of " + rupees(t.out) + " went to it."));
}
if (f.in.signum() > 0 && f.cashIn.divide(f.in, 4, RoundingMode.HALF_UP).compareTo(CASH_SHARE) >= 0) {
list.add(observation("MEDIUM", "High share of cash in credits", rupees(f.cashIn) + " of " + rupees(f.in)
+ " credited came in as cash."));
}
if (f.out.signum() > 0 && f.cashOut.divide(f.out, 4, RoundingMode.HALF_UP).compareTo(CASH_SHARE) >= 0) {
list.add(observation("MEDIUM", "High share of cash in debits", rupees(f.cashOut) + " of " + rupees(f.out)
+ " debited went out as cash."));
}
if (limit.signum() > 0 && f.peakUse.compareTo(LIMIT_USE) >= 0) {
list.add(observation("MEDIUM", "Sanctioned limit nearly used", "The account went down to " + rupees(f.lowest.negate())
+ " Dr, which is " + percent(f.peakUse) + " of the limit."));
}
if (f.volume.signum() > 0 && f.unrecorded.divide(f.volume, 4, RoundingMode.HALF_UP).compareTo(UNRECORDED_SHARE) >= 0) {
list.add(observation("INFO", "Other side not recorded", rupees(f.unrecorded) + " of the " + rupees(f.volume)
+ " moved has no counterparty recorded, so where it came from or went cannot be told."));
}
if (list.isEmpty()) {
list.add(observation("OK", "Nothing stands out", "None of the starting checks was triggered for this window."));
}
return list;
}
/** Why each kind of observation is worth a look, shown under it so an officer does not have to guess. */
private static final Map<String, String> WHY = Map.of(
"Declared turnover is missing",
"Without a declared turnover the bank cannot tell whether this activity is normal for the customer's business.",
"Credits far above the declared turnover",
"Money received well beyond what the business says it turns over can mean the account is used for something other than "
+ "the declared business.",
"Debits far above the declared turnover",
"Money paid out well beyond the declared turnover can mean funds are being moved on rather than spent in the business.",
"Money paid out soon after it came in",
"Funds that come in and go straight out within days can mean the account is only a pass-through.",
"One party handles most of the money",
"Depending on one party concentrates risk and can mean the account mainly serves that party.",
"Same party on both sides",
"Money going to a party and coming back from it can be a round trip with no real business behind it.",
"High share of cash in credits",
"Cash is harder to trace to its source than a bank transfer, so a high share of it deserves a look.",
"High share of cash in debits",
"Large cash withdrawals leave no trail of where the money went.",
"Sanctioned limit nearly used",
"An account drawn close to its sanctioned limit has little room left and may be under strain or being used up fast.",
"Other side not recorded",
"Without the party on the other side the bank cannot tell where the money came from or went to.");
private static Map<String, Object> observation(final String level, final String title, final String detail) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("level", level);
m.put("title", title);
m.put("detail", detail);
m.put("why", WHY.get(title));
return m;
}
private static String rupees(final BigDecimal value) {
return "₹" + String.format("%,.2f", value);
}
private static String percent(final BigDecimal fraction) {
return fraction.multiply(BigDecimal.valueOf(100)).setScale(1, RoundingMode.HALF_UP) + "%";
}
// ── Mode and party ───────────────────────────────────────────────────────────────────────────────────────────────
/**
* How a payment moved. The bank's payment types are only Cash and Transfer, so a cheque, a demand draft, NEFT, RTGS, IMPS,
* UPI or an internal transfer is told from the cheque number the payment recorded and from the words in its payment type
* and remarks. Where nothing says, it is the payment type itself.
*/
private static String modeOf(final boolean cash, final String paymentType, final String chequeNo, final String remarks) {
if (cash) {
return "Cash";
}
final String text = ((paymentType == null ? "" : paymentType) + " " + (remarks == null ? "" : remarks)).toUpperCase();
if (hasWord(text, "RTGS")) {
return "RTGS";
}
if (hasWord(text, "NEFT")) {
return "NEFT";
}
if (hasWord(text, "IMPS")) {
return "IMPS";
}
if (hasWord(text, "UPI")) {
return "UPI";
}
if (hasWord(text, "DEMAND DRAFT") || hasWord(text, "DD")) {
return "Demand Draft";
}
if (chequeNo != null && !chequeNo.isBlank() || hasWord(text, "CHEQUE") || hasWord(text, "CHECK")) {
return "Cheque";
}
if (hasWord(text, "INTERNAL TRANSFER")) {
return "Internal transfer";
}
return paymentType == null || paymentType.isBlank() ? "Not recorded" : paymentType;
}
/** Whether {@code word} stands alone in {@code text}, so "UPI" is not found inside some other word. */
private static boolean hasWord(final String text, final String word) {
return java.util.regex.Pattern.compile("(^|[^A-Z0-9])" + java.util.regex.Pattern.quote(word) + "([^A-Z0-9]|$)").matcher(text)
.find();
}
/** The party on the other side, named by the account the payment recorded; "Not recorded" when it recorded none. */
private static String partyOf(final Txn t) {
return t.partyAccount == null || t.partyAccount.isBlank() ? "Not recorded" : t.partyAccount;
}
private static BigDecimal scaledToWindow(final BigDecimal declaredMonthly, final int window) {
return declaredMonthly == null ? null
: declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP);
}
private static BigDecimal money(final Object value) { private static BigDecimal money(final Object value) {
return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString()); return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString());
} }
@@ -0,0 +1,67 @@
package org.apache.fineract.portfolio.approvals.api;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.CacheControl;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService;
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu.
* Numbers only: opening a tab still loads that tab's own list. The counting is in
* {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}.
*/
@Path("/v1/approvals/counts")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ApprovalCountsApiResource {
private final PlatformSecurityContext context;
private final ApprovalCountsReadPlatformService readService;
private final ApprovalCountsStreamService streamService;
/**
* The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held
* for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback.
*/
@GET
public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) {
final AppUser user = this.context.authenticatedUser();
final CacheControl noStore = new CacheControl();
noStore.setNoStore(true);
return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build();
}
/**
* Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change,
* so a screen neither polls nor reloads. The first event carries the numbers as they stand.
*/
@GET
@Path("stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) {
// Ask reverse proxies not to hold the events back.
response.setHeader("X-Accel-Buffering", "no");
response.setHeader("Cache-Control", "no-cache, no-store");
try {
this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse);
} catch (final RuntimeException e) {
sink.close();
throw e;
}
}
}
@@ -0,0 +1,14 @@
package org.apache.fineract.portfolio.approvals.data;
/**
* One Approval Center tab and the question that counts what waits on it.
*
* @param key
* the tab's route segment, which is also the key its badge reads on the screen
* @param permission
* the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees
* the tab
* @param sql
* a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan}
*/
public record ApprovalCounter(String key, String permission, String sql) {}
@@ -0,0 +1,27 @@
package org.apache.fineract.portfolio.approvals.data;
import com.google.gson.Gson;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal
* exactly when the numbers are, which is how a change is told from a repeat.
*/
public record ApprovalCountsData(long total, Map<String, Long> counts, String version) {
private static final Gson GSON = new Gson();
public static ApprovalCountsData of(final Map<String, Long> counts) {
final long total = counts.values().stream().mapToLong(Long::longValue).sum();
return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode()));
}
public String toJson() {
final Map<String, Object> json = new LinkedHashMap<>();
json.put("total", this.total);
json.put("counts", this.counts);
json.put("version", this.version);
return GSON.toJson(json);
}
}
@@ -0,0 +1,22 @@
package org.apache.fineract.portfolio.approvals.data;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
/**
* What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those
* tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then
* be used from any thread.
*
* @param tenant
* the tenant the counts are read from
* @param visible
* the tabs this caller may see, in the order the Approval Center shows them
* @param params
* the named parameters the tabs' queries use
* @param group
* equal for callers who are counted identically, so they can share one query
*/
public record ApprovalCountsPlan(FineractPlatformTenant tenant, List<ApprovalCounter> visible, MapSqlParameterSource params,
String group) {}
@@ -0,0 +1,122 @@
package org.apache.fineract.portfolio.approvals.service;
import java.util.List;
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
/**
* Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the
* one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new
* tab, add one line here and one badge on the screen.
*
* The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head
* office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command).
*/
public final class ApprovalCounterRegistry {
private ApprovalCounterRegistry() {}
/** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */
private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'";
/** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */
private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'";
/** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */
private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'";
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
+ "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id "
+ "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid "
+ "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name "
+ "AND p.entity_name = aud.entity_name)) ";
/** Where the person who raised a loan or account sits, for the office scope the tab applies. */
private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) "
+ "AND o.hierarchy LIKE :h)";
/** The one tab whose list is open to managers and head cashiers only. */
public static final String DISBURSEMENT_REQUESTS = "disbursement-requests";
/** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */
public static final List<ApprovalCounter> ALL = List.of(
// ── Fineract's own maker-checker and pending lists ────────────────────────────
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
new ApprovalCounter("client-approval", "READ_CLIENT",
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
new ApprovalCounter("loan-approval", "READ_LOAN",
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
+ "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE),
new ApprovalCounter("loan-disbursal", "READ_LOAN",
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
+ "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE),
new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN",
"SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"),
new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id "
+ "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 "
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
+ "AND COALESCE(ca.terminate_true, false) = false "
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
+ "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"),
new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"),
new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"),
// ── Requests raised by clerks, cashiers and managers ──────────────────────────
new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"),
new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"),
new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"),
new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"),
new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id "
+ "WHERE p.status = 100"),
new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id "
+ "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id "
+ "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"),
new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"),
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
new ApprovalCounter("dds-approvals", null,
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-issue-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-reprint-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-revoke-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"),
new ApprovalCounter("risk-change-approvals", null,
"SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'"));
}
@@ -0,0 +1,24 @@
package org.apache.fineract.portfolio.approvals.service;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.apache.fineract.useradministration.domain.AppUser;
/** Reads how many requests wait on each Approval Center tab, in one statement. */
public interface ApprovalCountsReadPlatformService {
/**
* Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the
* tenant of the current request.
*/
ApprovalCountsPlan planFor(AppUser user);
/** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */
ApprovalCountsData retrieve(ApprovalCountsPlan plan);
/**
* Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together
* cost the database one query. {@code fresh} skips the held answer.
*/
ApprovalCountsData retrieveHeld(AppUser user, boolean fresh);
}
@@ -0,0 +1,114 @@
package org.apache.fineract.portfolio.approvals.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
/**
* All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is
* one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked.
*/
@Service
@RequiredArgsConstructor
public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService {
/** Screens asking together read one answer instead of each running the statement. */
private static final long HOLD_MILLIS = 3_000L;
private record Held(long at, ApprovalCountsData data) {}
private final JdbcTemplate jdbcTemplate;
private final Map<String, Held> held = new ConcurrentHashMap<>();
@Override
public ApprovalCountsPlan planFor(final AppUser user) {
final List<ApprovalCounter> visible = new ArrayList<>();
for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) {
if (isVisible(user, counter)) {
visible.add(counter);
}
}
final String hierarchy = user.getOffice().getHierarchy();
final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER");
final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%")
.addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker);
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
// Callers counted the same way share one query: only a limited checker's numbers depend on who they are.
final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|'
+ (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(","));
return new ApprovalCountsPlan(tenant, visible, params, group);
}
@Override
public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) {
final Map<String, Long> counts = new LinkedHashMap<>();
final List<ApprovalCounter> visible = plan.visible();
if (!visible.isEmpty()) {
final StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < visible.size(); i++) {
sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i);
}
final Map<String, Object> row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(),
plan.params());
for (int i = 0; i < visible.size(); i++) {
final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L;
if (n > 0) {
counts.put(visible.get(i).key(), n);
}
}
}
return ApprovalCountsData.of(counts);
}
@Override
public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) {
final ApprovalCountsPlan plan = planFor(user);
final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId();
final long now = System.currentTimeMillis();
final Held recent = this.held.get(holdKey);
if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) {
return recent.data();
}
final ApprovalCountsData data = retrieve(plan);
this.held.put(holdKey, new Held(now, data));
if (this.held.size() > 500) {
this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS);
}
return data;
}
/** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */
private boolean isVisible(final AppUser user, final ApprovalCounter counter) {
if (counter.permission() != null) {
final boolean allowed = counter.permission().startsWith("READ_")
? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ")
: user.hasAnyPermission(counter.permission());
if (!allowed) {
return false;
}
}
return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user);
}
private boolean isChecker(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return true;
}
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
}
}
@@ -0,0 +1,145 @@
package org.apache.fineract.portfolio.approvals.service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.ws.rs.sse.OutboundSseEvent;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.springframework.stereotype.Service;
/**
* Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its
* numbers change, so it neither polls nor reloads.
*
* One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share
* its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the
* server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean
* connection, which bounds what a browser accumulates on one.
*/
@Service
@RequiredArgsConstructor
public class ApprovalCountsStreamService {
/** How often the open screens are looked at: the longest a change waits before it is pushed. */
private static final long CHECK_MILLIS = 1_000L;
private static final long PING_MILLIS = 15_000L;
private static final long LIFETIME_MILLIS = 300_000L;
/** A screen listening, and the version of the numbers it was last sent. */
private static final class Listener {
private final ApprovalCountsPlan plan;
private final SseEventSink sink;
private final Sse sse;
private final long expiresAt;
private volatile String version;
private volatile long lastSent;
private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) {
this.plan = plan;
this.sink = sink;
this.sse = sse;
this.expiresAt = expiresAt;
}
}
private final ApprovalCountsReadPlatformService readService;
private final Set<Listener> listeners = ConcurrentHashMap.newKeySet();
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> {
final Thread thread = new Thread(runnable, "approval-counts-stream");
thread.setDaemon(true);
return thread;
});
@PostConstruct
void startTicker() {
this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS);
}
@PreDestroy
void stopTicker() {
this.ticker.shutdownNow();
this.listeners.forEach(this::close);
}
/** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */
public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) {
final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS);
try {
publish(listener, this.readService.retrieve(plan));
} catch (final RuntimeException e) {
sink.close();
throw e;
}
this.listeners.add(listener);
}
/** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */
private void tick() {
if (this.listeners.isEmpty()) {
return;
}
final long now = System.currentTimeMillis();
final Map<String, ApprovalCountsData> computed = new HashMap<>();
for (final Listener listener : this.listeners) {
try {
if (listener.sink.isClosed() || now >= listener.expiresAt) {
close(listener);
continue;
}
ApprovalCountsData data = computed.get(listener.plan.group());
if (data == null) {
ThreadLocalContextUtil.setTenant(listener.plan.tenant());
data = this.readService.retrieve(listener.plan);
computed.put(listener.plan.group(), data);
}
if (!data.version().equals(listener.version)) {
publish(listener, data);
} else if (now - listener.lastSent >= PING_MILLIS) {
listener.lastSent = now;
send(listener, listener.sse.newEventBuilder().comment("ping").build());
}
} catch (final RuntimeException e) {
close(listener);
} finally {
ThreadLocalContextUtil.clearTenant();
}
}
}
private void publish(final Listener listener, final ApprovalCountsData data) {
listener.version = data.version();
listener.lastSent = System.currentTimeMillis();
send(listener, listener.sse.newEvent("counts", data.toJson()));
}
private void send(final Listener listener, final OutboundSseEvent event) {
listener.sink.send(event).whenComplete((sent, failure) -> {
if (failure != null) {
close(listener);
}
});
}
private void close(final Listener listener) {
this.listeners.remove(listener);
try {
listener.sink.close();
} catch (final RuntimeException ignored) {
// The screen is already gone.
}
}
}
@@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; 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.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator; import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator;
@@ -95,8 +97,16 @@ public class BranchDayRegisterApiResource {
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
private final FixedDepositInterestPostingReportService fdInterestPostingReportService; private final FixedDepositInterestPostingReportService fdInterestPostingReportService;
private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator; private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator;
private final InvestmentPurchaseReadPlatformService investmentPurchaseReadPlatformService;
private final InvestmentFdInterestService investmentFdInterestService;
private final Gson gson = new Gson(); 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 ────────────────────────────── // ── GET /v1/branchday ── the register itself ──────────────────────────────
/** /**
@@ -260,6 +270,13 @@ public class BranchDayRegisterApiResource {
final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate); final Map<String, Object> fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate);
result.put("fdInterestPending", fdInterest.get("pending")); result.put("fdInterestPending", fdInterest.get("pending"));
result.put("fdInterestDue", fdInterest.get("due")); 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); return gson.toJson(result);
} }
@@ -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";
}
}
@@ -83,12 +83,13 @@ public class KycContactAuditApiResource {
@Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.") @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, public String days(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("channel") final String channel, @QueryParam("status") final String status, @QueryParam("channel") final String channel, @QueryParam("status") final String status,
@QueryParam("search") final String search) { @QueryParam("search") final String search, @QueryParam("real") final Boolean real) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Object> args = new ArrayList<>(); final List<Object> args = new ArrayList<>();
final String where = filters(from, to, channel, status, search, args); final String where = filters(from, to, channel, status, search, real, args);
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, " final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
+ "COUNT(DISTINCT CASE WHEN l.delivery_status = 'SENT' THEN l.client_id END) AS notified, "
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, " + "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 = '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 = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, "
@@ -102,6 +103,8 @@ public class KycContactAuditApiResource {
item.put("date", String.valueOf(r.get("d"))); item.put("date", String.valueOf(r.get("d")));
item.put("notices", number(r.get("notices"))); item.put("notices", number(r.get("notices")));
item.put("customers", number(r.get("customers"))); item.put("customers", number(r.get("customers")));
// Customers a notice actually reached that day, as against customers it was tried for.
item.put("notifiedCustomers", number(r.get("notified")));
item.put("delivered", number(r.get("delivered"))); item.put("delivered", number(r.get("delivered")));
item.put("failed", number(r.get("failed"))); item.put("failed", number(r.get("failed")));
item.put("noContact", number(r.get("no_contact"))); item.put("noContact", number(r.get("no_contact")));
@@ -118,12 +121,12 @@ public class KycContactAuditApiResource {
@Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.") @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, public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("channel") final String channel, @QueryParam("status") final String status, @QueryParam("channel") final String channel, @QueryParam("status") final String status,
@QueryParam("search") final String search, @QueryParam("offset") final Integer offset, @QueryParam("search") final String search, @QueryParam("real") final Boolean real,
@QueryParam("limit") final Integer limit) { @QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME); this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Object> args = new ArrayList<>(); final List<Object> args = new ArrayList<>();
final String where = filters(from, to, channel, status, search, args); final String where = filters(from, to, channel, status, search, real, args);
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray()); final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray());
@@ -160,10 +163,193 @@ public class KycContactAuditApiResource {
return this.gson.toJson(out); return this.gson.toJson(out);
} }
/**
* One line per customer: how many notices, how they fared, and the latest. Newest activity first, a page at a time.
* Answers "who was contacted, and what happened" without paging through every day.
*/
@GET
@Path("customers")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Notices per customer", description = "Same filters as the day view. Returns total and rows.")
public String customers(@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("real") final Boolean real,
@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, real, args);
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(DISTINCT l.client_id)" + 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.client_id, c.display_name, c.account_no, COUNT(*) AS notices, "
+ "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 IN ('SKIPPED_NO_DESTINATION', 'PROVIDER_NOT_CONFIGURED') THEN 1 ELSE 0 END) AS not_sent, "
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded, "
+ "MAX(l.contacted_at) AS last_at" + FROM_CLAUSE + where
+ " GROUP BY l.client_id, c.display_name, c.account_no ORDER BY last_at DESC, l.client_id DESC LIMIT " + rows + " OFFSET "
+ skip;
final List<Map<String, Object>> list = new ArrayList<>();
final List<Long> pageIds = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final Map<String, Object> item = new LinkedHashMap<>();
item.put("clientId", r.get("client_id"));
item.put("clientName", r.get("display_name"));
item.put("accountNo", r.get("account_no"));
item.put("notices", number(r.get("notices")));
item.put("delivered", number(r.get("delivered")));
item.put("failed", number(r.get("failed")));
item.put("notSent", number(r.get("not_sent")));
item.put("recorded", number(r.get("recorded")));
final Object at = r.get("last_at");
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
pageIds.add(((Number) r.get("client_id")).longValue());
list.add(item);
}
// The latest notice's channel and outcome, for the customers on this page only.
if (!pageIds.isEmpty()) {
final String marks = String.join(",", pageIds.stream().map(x -> "?").toList());
final Map<Long, Map<String, Object>> byId = new LinkedHashMap<>();
list.forEach((m) -> byId.put(((Number) m.get("clientId")).longValue(), m));
final List<Object> latestArgs = new ArrayList<>(pageIds);
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(
"SELECT client_id, channel, delivery_status FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
+ ") ORDER BY contacted_at DESC, id DESC",
latestArgs.toArray())) {
final Map<String, Object> item = byId.get(((Number) r.get("client_id")).longValue());
if (item != null && !item.containsKey("lastChannel")) {
item.put("lastChannel", r.get("channel"));
item.put("lastStatus", r.get("delivery_status"));
}
}
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("total", total == null ? 0 : total);
out.put("rows", list);
return this.gson.toJson(out);
}
/**
* What the listed customers have been sent so far: the latest notice and which channels have been done.
* One round trip for a whole screen of customers, so a list can show "last notice" without a call per row.
*/
@GET
@Path("clients")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Notice summary for some customers", description = "ids is a comma-separated list of client ids (up to 500).")
public String clients(@QueryParam("ids") final String ids) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Long> clientIds = new ArrayList<>();
if (StringUtils.isNotBlank(ids)) {
for (final String part : ids.split(",")) {
if (StringUtils.isNumeric(part.trim()) && clientIds.size() < MAX_PAGE) {
clientIds.add(Long.valueOf(part.trim()));
}
}
}
if (clientIds.isEmpty()) {
return this.gson.toJson(new ArrayList<>());
}
final String marks = String.join(",", clientIds.stream().map(x -> "?").toList());
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT client_id, channel, delivery_status, contacted_at, message_body FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
+ ") ORDER BY contacted_at DESC, id DESC",
clientIds.toArray());
final Map<Long, Map<String, Object>> byClient = new LinkedHashMap<>();
// Every customer asked for comes back, with or without notices, so the screen knows who has no mobile or email.
for (final Map<String, Object> c : this.jdbcTemplate.queryForList(
"SELECT c.id, c.mobile_no, c.email_address, "
+ "COALESCE((SELECT SUM(s.account_balance_derived) FROM m_savings_account s WHERE s.client_id = c.id AND s.status_enum = 300), 0) "
+ "+ COALESCE((SELECT SUM(a.account_balance_derived) FROM m_current_account a WHERE a.client_id = c.id AND a.status_enum = 300), 0) AS balance "
+ "FROM m_client c WHERE c.id IN (" + marks + ")", clientIds.toArray())) {
final Long id = ((Number) c.get("id")).longValue();
final Map<String, Object> m = new LinkedHashMap<>();
m.put("clientId", id);
m.put("count", 0);
m.put("lastAt", null);
m.put("lastChannel", null);
m.put("lastStatus", null);
m.put("doneChannels", new ArrayList<String>());
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
// What the customer holds in open savings and current accounts.
m.put("balance", c.get("balance"));
m.put("hasMobile", StringUtils.isNotBlank((String) c.get("mobile_no")));
m.put("hasEmail", StringUtils.isNotBlank((String) c.get("email_address")));
// Masked, never the full value: the screen only needs to show that something is on file.
m.put("mobileMasked", maskMobile((String) c.get("mobile_no")));
m.put("emailMasked", maskEmail((String) c.get("email_address")));
byClient.put(id, m);
}
for (final Map<String, Object> r : rows) {
final Long clientId = ((Number) r.get("client_id")).longValue();
final Map<String, Object> item = byClient.computeIfAbsent(clientId, k -> {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("clientId", k);
m.put("count", 0);
m.put("lastAt", null);
m.put("lastChannel", null);
m.put("lastStatus", null);
m.put("doneChannels", new ArrayList<String>());
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
return m;
});
if (item.get("lastAt") == null) {
// Rows come newest first, so the first one seen is the latest notice.
final Object at = r.get("contacted_at");
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
item.put("lastChannel", r.get("channel"));
item.put("lastStatus", r.get("delivery_status"));
}
item.put("count", ((Integer) item.get("count")) + 1);
// A channel counts as done once it reached the customer, or was recorded (a call or a visit).
final String status = String.valueOf(r.get("delivery_status"));
final String channel = String.valueOf(r.get("channel"));
@SuppressWarnings("unchecked")
final List<String> done = (List<String>) item.get("doneChannels");
if (("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) && !done.contains(channel)) {
done.add(channel);
}
// The same, by the stage the notice was written for, so a notice sent at an earlier stage is not taken for this one.
// A call, a visit or any other record that is not a stage notice is filed under ANY: it counts at whatever stage the customer is at.
final String found = stageOfNotice((String) r.get("message_body"));
final String stage = found == null ? "ANY" : found;
if ("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) {
@SuppressWarnings("unchecked")
final Map<String, List<String>> byStage = (Map<String, List<String>>) item.get("doneByStage");
@SuppressWarnings("unchecked")
final Map<String, Integer> counts = (Map<String, Integer>) item.get("noticesByStage");
counts.merge(stage, 1, Integer::sum);
final List<String> inStage = byStage.computeIfAbsent(stage, k -> new ArrayList<>());
if (!inStage.contains(channel)) {
inStage.add(channel);
}
}
}
return this.gson.toJson(new ArrayList<>(byClient.values()));
}
/** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */ /** 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, private String filters(final String from, final String to, final String channel, final String status, final String search,
final List<Object> args) { final Boolean real, final List<Object> args) {
final StringBuilder where = new StringBuilder(); final StringBuilder where = new StringBuilder();
if (Boolean.TRUE.equals(real)) {
// An attempt that went nowhere (no number or address on file, or no gateway set up) is not a notice a customer received.
where.append(" AND l.delivery_status IN ('SENT', 'FAILED', 'NOT_APPLICABLE')");
}
if (StringUtils.isNotBlank(from)) { if (StringUtils.isNotBlank(from)) {
where.append(" AND l.contacted_at >= ?"); where.append(" AND l.contacted_at >= ?");
args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay())); args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay()));
@@ -190,6 +376,49 @@ public class KycContactAuditApiResource {
return where.toString(); return where.toString();
} }
/**
* The stage a notice was written for, read from its wording (the composer words every stage differently). Null for a call,
* a visit or any message that is not one of the stage notices.
*/
private static String stageOfNotice(final String body) {
if (StringUtils.isBlank(body)) {
return null;
}
if (body.startsWith("Your KYC expires on")) {
return "EXPIRING";
}
if (body.startsWith("Your KYC expired on")) {
if (body.contains("account is now inoperative")) {
return "INOPERATIVE";
}
return body.contains("debits on your account are now stopped") ? "PARTIAL_FREEZE" : "GRACE";
}
if (body.startsWith("Your account ")) {
if (body.contains("has been closed")) {
return "ESCHEAT";
}
return body.contains("is dormant") ? "DORMANT" : body.contains("is inactive") ? "INACTIVE" : null;
}
return null;
}
private static String maskMobile(final String mobile) {
if (StringUtils.isBlank(mobile)) {
return null;
}
final String digits = mobile.trim();
return digits.length() <= 4 ? digits : "XXXXXX" + digits.substring(digits.length() - 4);
}
private static String maskEmail(final String email) {
if (StringUtils.isBlank(email) || !email.contains("@")) {
return StringUtils.isBlank(email) ? null : email;
}
final String trimmed = email.trim();
final int at = trimmed.indexOf('@');
return trimmed.substring(0, Math.min(1, at)) + "***" + trimmed.substring(at);
}
private static long number(final Object value) { private static long number(final Object value) {
return value == null ? 0 : ((Number) value).longValue(); return value == null ? 0 : ((Number) value).longValue();
} }
@@ -108,6 +108,7 @@ public class KycNoticeQueueApiResource {
final JsonObject out = new JsonObject(); final JsonObject out = new JsonObject();
out.addProperty("queued", result.queued()); out.addProperty("queued", result.queued());
out.addProperty("skipped", result.skipped()); out.addProperty("skipped", result.skipped());
out.addProperty("unreachable", result.unreachable());
return out.toString(); return out.toString();
} }
@@ -24,7 +24,6 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.util.List; import java.util.List;
@@ -70,14 +69,4 @@ public class KycRiskRulesApiResource {
final KycRiskRuleData rule = this.writePlatformService.createRule(request); final KycRiskRuleData rule = this.writePlatformService.createRule(request);
return this.toApiJsonSerializer.serialize(rule); return this.toApiJsonSerializer.serialize(rule);
} }
@POST
@Path("{ruleId}/mark-normal")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Mark a rule as normal", description = "Ends the trial of a newly added rule. It stays in the catalog as a normal rule.")
public String markNormal(@PathParam("ruleId") final Long ruleId) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
return this.toApiJsonSerializer.serialize(this.writePlatformService.markNormal(ruleId));
}
} }
@@ -66,10 +66,6 @@ public final class KycRiskRuleData implements Serializable {
private final BigDecimal txnMaxLimitAmount; private final BigDecimal txnMaxLimitAmount;
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */ /** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
private final boolean ccRelevant; private final boolean ccRelevant;
/** True while a newly authored rule is on trial; it is listed first until an officer marks it normal. */
private final boolean onTrial;
/** When the trial began (ISO date), or null for a rule that is not on trial. */
private final String trialStartedOn;
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId, public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName, final Long occupationCategoryCvId, final String occupationCategoryName,
@@ -82,8 +78,7 @@ public final class KycRiskRuleData implements Serializable {
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag, final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays, final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk, final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant, final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant) {
final boolean onTrial, final String trialStartedOn) {
this.id = id; this.id = id;
this.ruleName = ruleName; this.ruleName = ruleName;
this.ruleKind = ruleKind; this.ruleKind = ruleKind;
@@ -114,8 +109,6 @@ public final class KycRiskRuleData implements Serializable {
this.txnMinLimitAmount = txnMinLimitAmount; this.txnMinLimitAmount = txnMinLimitAmount;
this.txnMaxLimitAmount = txnMaxLimitAmount; this.txnMaxLimitAmount = txnMaxLimitAmount;
this.ccRelevant = ccRelevant; this.ccRelevant = ccRelevant;
this.onTrial = onTrial;
this.trialStartedOn = trialStartedOn;
} }
public Long getId() { public Long getId() {
@@ -237,12 +230,4 @@ public final class KycRiskRuleData implements Serializable {
public boolean isCcRelevant() { public boolean isCcRelevant() {
return ccRelevant; 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") @Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
private String txnRuleType; 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; private String txnAppliesTo;
@Schema(description = "CREDIT (money in) or DEBIT (money out). Omit for either direction.", example = "CREDIT")
private String txnDirection;
@Schema(description = "True when the rule is about cash only. Defaults to false.")
private Boolean txnCashOnly;
@Schema(description = "True when the scenario watches Cash Credit accounts, so the Rules Master CC chip lists it.")
private Boolean ccRelevant;
/** /**
* True when the officer has described a live-transaction rule rather than a pure KYC profile * True when the officer has described a live-transaction rule rather than a pure KYC profile
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted. * rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
*/ */
public boolean hasTransactionRule() { public boolean hasTransactionRule() {
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|| (this.txnWindowDays != null && this.txnWindowDays > 0) || (this.txnWindowDays != null && this.txnWindowDays > 0);
|| (this.txnDirection != null && !this.txnDirection.isBlank()) || Boolean.TRUE.equals(this.txnCashOnly)
|| (this.txnAppliesTo != null && !this.txnAppliesTo.isBlank() && !"ALL".equalsIgnoreCase(this.txnAppliesTo.trim()));
} }
} }
@@ -122,28 +122,36 @@ public class KycNoticeComposer {
return "Your account is inoperative"; return "Your account is inoperative";
} }
/** What the customer is told, by the stage they are at: where they stand and what happens next. */ /** What the customer is told, by the stage they are at: where they stand and what happens next, and in how many days. */
public static String kycMessage(final LocalDate expiry, final long offset) { public static String kycMessage(final LocalDate expiry, final long offset) {
final String expired = expiry.format(DATE); final String expired = expiry.format(DATE);
final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE); final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE);
final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE); final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE);
final String freezeIn = days(DEBIT_FREEZE_DAYS - offset);
final String inoperativeIn = days(INOPERATIVE_DAYS - offset);
if (offset < 0) { if (offset < 0) {
return "Your KYC expires on " + expired + " (in " + (-offset) + " days). Please visit your branch with valid ID and address" return "Your KYC expires on " + expired + " (in " + days(-offset) + "). 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 + " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze + " (in " + freezeIn
+ " and the account will become inoperative on " + inoperative + "."; + ") and the account will become inoperative on " + inoperative + " (in " + inoperativeIn + ").";
} }
if (offset < DEBIT_FREEZE_DAYS) { 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" 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 + "."; + " account will stop on " + freeze + " (in " + freezeIn + ") and the account will become inoperative on "
+ inoperative + " (in " + inoperativeIn + ").";
} }
if (offset < INOPERATIVE_DAYS) { if (offset < INOPERATIVE_DAYS) {
return "Your KYC expired on " + expired + " and debits on your account are now stopped. Please renew your KYC at your" 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 + "."; + " branch; if it is not renewed, the account will become inoperative on " + inoperative + " (in " + inoperativeIn
+ ").";
} }
return "Your KYC expired on " + expired + " and your account is now inoperative. Please visit your branch with valid" 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."; + " documents to renew your KYC and have the account restored.";
} }
private static String days(final long n) {
return n == 1 ? "1 day" : n + " days";
}
// ── Inactive / dormant / escheated accounts ──────────────────────────────── // ── Inactive / dormant / escheated accounts ────────────────────────────────
public static String dormantSubject(final DormantAccountData account) { public static String dormantSubject(final DormantAccountData account) {
@@ -166,7 +174,7 @@ public class KycNoticeComposer {
if (sub != null && sub == SUB_STATUS_DORMANT) { if (sub != null && sub == SUB_STATUS_DORMANT) {
final String escheat = account.getDaysToEscheat() != null && account.getDaysToEscheat() > 0 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 " ? " If it stays dormant, the balance will be transferred to RBI's Depositor Education and Awareness Fund on "
+ today.plusDays(account.getDaysToEscheat()).format(DATE) + "." + today.plusDays(account.getDaysToEscheat()).format(DATE) + " (in " + days(account.getDaysToEscheat()) + ")."
: ""; : "";
return "Your account " + account.getAccountNo() + " is dormant: there has been no transaction by you since " + since 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" + ", so debits are stopped. To reactivate it, please visit your branch with valid KYC documents to verify your"
@@ -174,7 +182,7 @@ public class KycNoticeComposer {
} }
final String dormant = account.getDaysToDormancy() != null && account.getDaysToDormancy() > 0 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) ? " If there is still no transaction, it will become dormant on " + today.plusDays(account.getDaysToDormancy()).format(DATE)
+ " and debits will be stopped." + " (in " + days(account.getDaysToDormancy()) + ") and debits will be stopped."
: ""; : "";
return "Your account " + account.getAccountNo() + " is inactive: there has been no transaction by you since " + since 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; + ". Please make a transaction or visit your branch." + dormant;
@@ -36,8 +36,11 @@ public interface KycNoticeQueueService {
record QueueItem(Long clientId, String noticeType, String channel, String accountNo, String stageLabel) { 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) { * How many were queued, how many were left out because the same notice was already waiting, and how many because the
* customer has no mobile number or email address to send it to.
*/
record EnqueueResult(int queued, int skipped, int unreachable) {
} }
/** /**
@@ -78,6 +78,7 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
int queued = 0; int queued = 0;
int skipped = 0; int skipped = 0;
int unreachable = 0;
for (final QueueItem item : items) { for (final QueueItem item : items) {
final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel()); final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel());
if (item.clientId() == null || channel == null || !channel.isPlatformSent()) { if (item.clientId() == null || channel == null || !channel.isPlatformSent()) {
@@ -87,6 +88,16 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) { if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) {
throw validationError("noticeType", "Notice type must be KYC or DORMANCY."); throw validationError("noticeType", "Notice type must be KYC or DORMANCY.");
} }
// A customer with no mobile number (or no email, for Email) cannot be sent this. Queuing it would only write a
// "not sent" record every time, so it is left out and counted — those customers are found and reached another way.
final Map<String, Object> contact = this.jdbcTemplate.queryForList("SELECT mobile_no, email_address FROM m_client WHERE id = ?",
item.clientId()).stream().findFirst().orElse(null);
final String destination = contact == null ? null
: (String) contact.get(channel == KycContactChannel.EMAIL ? "email_address" : "mobile_no");
if (StringUtils.isBlank(destination)) {
unreachable++;
continue;
}
// The same notice already waiting is not queued twice; a double click must not message a customer twice. // 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 = ? " 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, + "AND channel = ? AND notice_type = ? AND status IN ('" + PENDING + "', '" + SENDING + "')", Integer.class,
@@ -102,7 +113,7 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
Timestamp.valueOf(when), Timestamp.valueOf(now)); Timestamp.valueOf(when), Timestamp.valueOf(now));
queued++; queued++;
} }
return new EnqueueResult(queued, skipped); return new EnqueueResult(queued, skipped, unreachable);
} }
@Override @Override
@@ -57,7 +57,15 @@ import org.springframework.stereotype.Service;
public class KycReminderServiceImpl implements KycReminderService { public class KycReminderServiceImpl implements KycReminderService {
/** Days from expiry (negative = before) at which a customer's KYC stage changes. */ /** 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 }; /**
* Days from expiry (negative = before) at which a KYC reminder goes out: 30, 15, 7 and 1 day before it expires, the
* day it expires, then 7 and 1 day before debits stop (day 90) and before the account goes inoperative (day 180), and
* on each of those two days themselves.
*/
private static final int[] KYC_MILESTONES = { -30, -15, -7, -1, 0, 83, 89, 90, 173, 179, 180 };
/** An account is warned 7 days and 1 day before it moves to its next stage. */
private static final long WARN_DAYS = 7;
private static final int SUB_STATUS_INACTIVE = 100; private static final int SUB_STATUS_INACTIVE = 100;
private static final int SUB_STATUS_DORMANT = 200; private static final int SUB_STATUS_DORMANT = 200;
@@ -137,7 +145,11 @@ public class KycReminderServiceImpl implements KycReminderService {
int attempted = 0; int attempted = 0;
for (final DormantAccountData account : worst.values()) { for (final DormantAccountData account : worst.values()) {
try { try {
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + "]"; // The step is "E" (the account has just entered this stage), "D7" (a week before the next) or "D1" (the day
// before). The latest one reached is what the customer is told, once.
final Long remaining = account.getSubStatus() == SUB_STATUS_DORMANT ? account.getDaysToEscheat() : account.getDaysToDormancy();
final String step = remaining != null && remaining <= 1 ? "D1" : remaining != null && remaining <= WARN_DAYS ? "D7" : "E";
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + " " + step + "]";
final String subject = KycNoticeComposer.dormantSubject(account); final String subject = KycNoticeComposer.dormantSubject(account);
attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today)); attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today));
} catch (final RuntimeException e) { } catch (final RuntimeException e) {
@@ -93,9 +93,7 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null, txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
txnRule != null ? txnRule.getMinLimitAmount() : null, txnRule != null ? txnRule.getMinLimitAmount() : null,
txnRule != null ? txnRule.getMaxLimitAmount() : null, txnRule != null ? txnRule.getMaxLimitAmount() : null,
rule.isCcRelevant(), rule.isCcRelevant()
rule.isOnTrial(),
rule.getTrialStartedOn() == null ? null : rule.getTrialStartedOn().toString()
); );
} }
} }
@@ -31,7 +31,4 @@ public interface KycRiskRulesWritePlatformService {
* @return the persisted rule exactly as the catalog GET would return it * @return the persisted rule exactly as the catalog GET would return it
*/ */
KycRiskRuleData createRule(KycRiskRuleRequest request); 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.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; 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.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlRuleType; import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule; import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
@@ -82,13 +81,6 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
rule.setOccupation(occupation); 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); final ClientRiskRule saved = this.repository.saveAndFlush(rule);
if (request.hasTransactionRule()) { if (request.hasTransactionRule()) {
@@ -146,21 +138,8 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase()); : AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
final AmlTransactionRule transactionRule = AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType,
ruleType, request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount()); 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);
} }
private Integer nextOrderPosition() { private Integer nextOrderPosition() {
@@ -203,10 +182,6 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class); validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.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()) { if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException(errors); throw new PlatformApiDataValidationException(errors);
@@ -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,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Constants for the Treasury Investment masters (Bank / Party Master and Script Master), modelled on the bank's existing
* CBS Investment screens.
*/
public final class InvestmentApiConstants {
private InvestmentApiConstants() {}
public static final String ENTITY_PARTY = "INVESTMENTPARTY";
public static final String ENTITY_SCRIPT = "INVESTMENTSCRIPT";
public static final String RESOURCE_PARTY = "investmentparty";
public static final String RESOURCE_SCRIPT = "investmentscript";
/** Instrument ("Bond Type") an investment party / script belongs to. */
public static final Set<String> BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND", "FD", "SHARE");
/** Bond types that carry coupon terms (ISIN, interest rate, issue date are mandatory for these). */
public static final Set<String> DEBT_BOND_TYPES = Set.of("GOVT_SECURITY", "NON_SLR", "BOND");
/** SLR classification on the Script Master ("GOI" = Central Government). */
public static final Set<String> SLR_TYPES = Set.of("GOI", "STATE_GOVT", "TREASURY", "NON_SLR");
/** Script Code prefix per bond type, e.g. G00001 for the first Govt Security. */
public static final Map<String, String> SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND",
"M", "FD", "F", "SHARE", "S");
/** Bond types that are non-SLR investments, counted against the RBI limit of 10% of deposits. */
public static final Set<String> NON_SLR_BOND_TYPES = Set.of("NON_SLR", "BOND", "MUTUAL_FUND");
/** Bond types that must carry a credit rating: corporate bonds, CPs and CDs. */
public static final Set<String> RATED_BOND_TYPES = Set.of("NON_SLR", "BOND");
/** Credit ratings on the Script Master: long-term (bonds) and short-term (CP / CD) scales, best first. */
public static final List<String> RATINGS = List.of("AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB", "BBB-", "BB+", "BB",
"BB-", "B+", "B", "B-", "C", "D", "A1+", "A1", "A2+", "A2", "A3+", "A3", "A4+", "A4", "UNRATED");
/** RBI: corporate bonds rated "A" or higher, CPs and CDs rated "A2" or higher. */
public static final Set<String> ELIGIBLE_RATINGS = Set.of("AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "A1+", "A1", "A2+", "A2");
/** Credit rating agencies registered with SEBI. */
public static final List<String> RATING_AGENCIES = List.of("CRISIL", "ICRA", "CARE", "INDIA_RATINGS", "ACUITE", "BRICKWORK",
"INFOMERICS");
/** RBI: only debt and money-market mutual funds; their sub types start with this. */
public static final String DEBT_MF_SUB_TYPE_PREFIX = "DEBT";
public static final String PARTY_CODE_PREFIX = "PTY-";
// Common parameters
public static final String BOND_TYPE = "bondType";
public static final String GL_ACCOUNT_ID = "glAccountId";
public static final String INTEREST_GL_ACCOUNT_ID = "interestGlAccountId";
public static final String ADDRESS1 = "address1";
public static final String ADDRESS2 = "address2";
public static final String ADDRESS3 = "address3";
public static final String CITY = "city";
public static final String PINCODE = "pincode";
public static final String PHONE = "phone";
public static final String FAX = "fax";
public static final String ACTIVE = "active";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
// Party parameters
public static final String PARTY_NAME = "partyName";
public static final String BANK_TYPE = "bankType";
public static final String RBI_CODE = "rbiCode";
public static final String EXPOSURE_LIMIT = "limit";
public static final String LEI_NO = "leiNo";
public static final String CP_BANK_NAME = "cpBankName";
public static final String CP_BRANCH_NAME = "cpBranchName";
public static final String CP_ACCOUNT_NO = "cpAccountNo";
public static final String CP_IFSC = "cpIfsc";
public static final String TDS_APPLICABLE = "tdsApplicable";
public static final String TDS_RATE = "tdsRate";
// Script parameters
public static final String SLR_TYPE = "slrType";
public static final String SCRIPT_DESC = "scriptDesc";
public static final String ISIN = "isin";
public static final String FACE_VALUE_PER_UNIT = "faceValuePerUnit";
public static final String INT_RATE = "intRate";
public static final String ISSUE_DATE = "issueDate";
public static final String MATURITY_DATE = "maturityDate";
public static final String COMPANY_NAME = "companyName";
public static final String CORP_CODE = "corpCode";
public static final String RATING = "rating";
public static final String RATING_AGENCY = "ratingAgency";
public static final String LISTED = "listed";
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX,
BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
TDS_APPLICABLE, TDS_RATE, 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,
RATING, RATING_AGENCY, LISTED, ACTIVE, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentBucketReportData;
import org.apache.fineract.portfolio.investment.service.InvestmentBucketReportService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/bucket-report")
@Component
@Tag(name = "Investment Bucket Wise", description = "Investments held on a date, split by the time left to maturity.")
@RequiredArgsConstructor
public class InvestmentBucketReportApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentBucketReportService reportService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Bucket Wise Investment", description = "asOnDate dd-MM-yyyy; default today")
public InvestmentBucketReportData report(@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
if (asOnDate == null || asOnDate.isBlank()) {
return this.reportService.report(DateUtils.getBusinessLocalDate());
}
try {
return this.reportService.report(LocalDate.parse(asOnDate.trim(), DATE));
} catch (final DateTimeParseException e) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.date.invalid", "Enter the date as DD-MM-YYYY",
asOnDate);
}
}
}
@@ -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,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentDiaryData;
import org.apache.fineract.portfolio.investment.service.InvestmentDiaryService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/diary")
@Component
@Tag(name = "Investment Due Date Diary", description = "Coupons, provisions, amortisation, FD payouts and maturities due in a period.")
@RequiredArgsConstructor
public class InvestmentDiaryApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
/** Default window when no dates are given: today and the next 30 days. */
private static final int DEFAULT_DAYS = 30;
private final PlatformSecurityContext context;
private final InvestmentDiaryService diaryService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Due Date Diary for all investments", description = "fromDate / toDate dd-MM-yyyy; default today to 30 days ahead")
public InvestmentDiaryData diary(@QueryParam("fromDate") @Parameter(description = "dd-MM-yyyy") final String fromDate,
@QueryParam("toDate") @Parameter(description = "dd-MM-yyyy") final String toDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate from = date(fromDate, today);
final LocalDate to = date(toDate, from.plusDays(DEFAULT_DAYS));
if (to.isBefore(from)) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.diary.dates", "The To date cannot be before the From date");
}
return this.diaryService.diary(from, to);
}
private static LocalDate date(final String value, final LocalDate fallback) {
if (value == null || value.isBlank()) {
return fallback;
}
try {
return LocalDate.parse(value.trim(), DATE);
} catch (final DateTimeParseException e) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.diary.date.invalid", "Enter the date as DD-MM-YYYY", value);
}
}
}
@@ -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,199 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentFdClosureData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdDueData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData;
import org.apache.fineract.portfolio.investment.service.InvestmentFdClosureService;
import org.apache.fineract.portfolio.investment.service.InvestmentFdInterestService;
import org.apache.fineract.portfolio.investment.service.InvestmentFdService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/fds")
@Component
@Tag(name = "Investment FD", description = "Fixed deposits the bank places with other banks.")
@RequiredArgsConstructor
public class InvestmentFdApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentFdService fdService;
private final InvestmentFdClosureService closureService;
private final InvestmentFdInterestService interestService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "All FDs placed with other banks")
public List<InvestmentFdData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.fdService.retrieveAll();
}
@GET
@Path("quote")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Preview an FD: interest, maturity amount and the RBI limits", description = "Dates dd-MM-yyyy")
public InvestmentFdData quote(@QueryParam("partyId") final Long partyId, @QueryParam("amount") final BigDecimal amount,
@QueryParam("intRate") final BigDecimal intRate, @QueryParam("dealDate") final String dealDate,
@QueryParam("maturityDate") final String maturityDate, @QueryParam("payout") final String payout,
@QueryParam("calcType") final String calcType) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.fdService.quote(partyId, amount, intRate, date(dealDate), date(maturityDate), payout, calcType);
}
@GET
@Path("due")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "FD interest vouchers and maturities due", description = "Due now or within withinDays (default 2)")
public List<InvestmentFdDueData> due(@QueryParam("withinDays") final Integer withinDays) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.interestService.due(withinDays == null ? InvestmentFdApiConstants.DUE_WITHIN_DAYS : withinDays);
}
@GET
@Path("{fdId}/interest-schedule")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Interest lines of an FD", description = "31 Mar / 30 Sep provisions, payouts and maturity")
public InvestmentFdScheduleData schedule(@PathParam("fdId") final Long fdId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.interestService.schedule(fdId);
}
@POST
@Path("{fdId}/interest")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post the next interest line of an FD", description = "lineType PROVISION | PAYOUT, intDate; payout: receivedAmount, tds")
public CommandProcessingResult postInterest(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return this.commandsSourceWritePlatformService
.logCommandSource(new CommandWrapperBuilder().postInvestmentFdInterest(fdId).withJson(apiRequestBodyAsJson).build());
}
@GET
@Path("{fdId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "One FD")
public InvestmentFdData retrieveOne(@PathParam("fdId") @Parameter(description = "fdId") final Long fdId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.fdService.retrieveOne(fdId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Place an FD", description = "Dr the bank's FD GL / Cr our bank GL, dated the deal date; refused beyond the RBI limits.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentFd().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@GET
@Path("{fdId}/closure-quote")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Preview a maturity, premature closure or renewal", description = "type MATURITY | PREMATURE | RENEWAL")
public InvestmentFdClosureData closureQuote(@PathParam("fdId") final Long fdId, @QueryParam("type") final String type,
@QueryParam("closeDate") final String closeDate, @QueryParam("receivedAmount") final BigDecimal receivedAmount,
@QueryParam("tds") final BigDecimal tds, @QueryParam("renewalAmount") final BigDecimal renewalAmount,
@QueryParam("principalBankGlAccountId") final Long principalBankGlAccountId,
@QueryParam("interestBankGlAccountId") final Long interestBankGlAccountId,
@QueryParam("diffBankGlAccountId") final Long diffBankGlAccountId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY);
return this.closureService.quote(fdId, type, date(closeDate), receivedAmount, tds, renewalAmount, principalBankGlAccountId,
interestBankGlAccountId, diffBankGlAccountId);
}
@POST
@Path("{fdId}/mature")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Close an FD at maturity", description = "Dated the maturity date; amount as credited by the bank, optional TDS.")
public CommandProcessingResult mature(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return this.commandsSourceWritePlatformService
.logCommandSource(new CommandWrapperBuilder().matureInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
}
@POST
@Path("{fdId}/premature")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Close an FD before maturity", description = "Not calculated: the amount received (and any penal interest) is entered.")
public CommandProcessingResult premature(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return this.commandsSourceWritePlatformService
.logCommandSource(new CommandWrapperBuilder().prematureInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
}
@POST
@Path("{fdId}/renew")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Renew an FD at maturity", description = "New certificate, rate and maturity; money added or taken out goes through a bank GL.")
public CommandProcessingResult renew(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
return this.commandsSourceWritePlatformService
.logCommandSource(new CommandWrapperBuilder().renewInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build());
}
@POST
@Path("deposit-base")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record total deposits as on a 31 March", description = "The base of the RBI limits for the following year.")
public CommandProcessingResult setDepositBase(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().setInvestmentDepositBase().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
private static LocalDate date(final String value) {
if (StringUtils.isBlank(value)) {
return null;
}
try {
return LocalDate.parse(value.trim(), DATE);
} catch (final java.time.format.DateTimeParseException e) {
// A half-typed date is a bad request, not a server error.
throw new GeneralPlatformDomainRuleException("error.msg.investmentfd.date.invalid", "Enter the date as DD-MM-YYYY", value);
}
}
}
@@ -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,79 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.Month;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestReportData;
import org.apache.fineract.portfolio.investment.service.InvestmentInterestReportService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/interest-report")
@Component
@Tag(name = "Investment Interest Received & Receivable", description = "Interest received in a period and receivable at its end.")
@RequiredArgsConstructor
public class InvestmentInterestReportApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentInterestReportService reportService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Total Interest Received & Receivable", description = "fromDate / toDate dd-MM-yyyy; default 1 April of the "
+ "current financial year to today")
public InvestmentInterestReportData report(@QueryParam("fromDate") @Parameter(description = "dd-MM-yyyy") final String fromDate,
@QueryParam("toDate") @Parameter(description = "dd-MM-yyyy") final String toDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate fyStart = LocalDate.of(today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1, Month.APRIL, 1);
final LocalDate from = date(fromDate, fyStart);
final LocalDate to = date(toDate, today);
if (to.isBefore(from)) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.dates", "The To date cannot be before the From date");
}
return this.reportService.report(from, to);
}
private static LocalDate date(final String value, final LocalDate fallback) {
if (value == null || value.isBlank()) {
return fallback;
}
try {
return LocalDate.parse(value.trim(), DATE);
} catch (final DateTimeParseException e) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.date.invalid", "Enter the date as DD-MM-YYYY", value);
}
}
}
@@ -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,76 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import java.util.Set;
/** Constants for the Investment Purchase Entry (a deal that creates a holding). */
public final class InvestmentPurchaseApiConstants {
private InvestmentPurchaseApiConstants() {}
public static final String ENTITY = "INVESTMENTPURCHASE";
public static final String RESOURCE = "investmentpurchase";
public static final Set<String> SETTLEMENT_TYPES = Set.of("T+0", "T+1", "T+2");
public static final Set<String> CATEGORIES = Set.of("HTM (Held To Maturity)", "AFS (Available For Sale)", "HFT (Held For Trading)");
/** Saved deal; nothing posted to GL until it is settled. */
public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
/** Settled: posted to GL on the settlement date, now a holding. */
public static final String STATUS_ACTIVE = "ACTIVE";
/** Every unit sold before maturity. */
public static final String STATUS_SOLD = "SOLD";
/** Repaid at maturity. */
public static final String STATUS_REDEEMED = "REDEEMED";
/** Dropped before settlement; kept so its deal and certificate numbers are never given out again. */
public static final String STATUS_DROPPED = "DROPPED";
/** GLs the settlement entry debits besides the script's investment GL. */
public static final String BROKEN_PERIOD_INTEREST_GL_CODE = "500606";
public static final String STAMP_DUTY_GL_CODE = "500601";
public static final String BROKERAGE_GL_CODE = "500602";
public static final String OTHER_CHARGES_GL_CODE = "500603";
public static final String BOND_TYPE = "bondType";
public static final String SCRIPT_ID = "scriptId";
public static final String PARTY_ID = "partyId";
public static final String DEAL_DATE = "dealDate";
public static final String DEAL_TIME = "dealTime";
public static final String SETTLEMENT_TYPE = "settlementType";
public static final String FACE_VALUE = "faceValue";
public static final String PURCHASE_RATE = "purchaseRate";
public static final String CONSIDER_AMT = "considerAmt";
public static final String NAV = "nav";
public static final String STAMP_DUTY = "stampDuty";
public static final String BROKERAGE = "brokerage";
public static final String OTHER_CHARGES = "otherCharges";
public static final String CATEGORY = "category";
public static final String LAST_INT_DATE = "lastIntDate";
public static final String CR_GL_ACCOUNT_ID = "crGlAccountId";
public static final String SUB_TYPE = "subType";
/** Optional legacy fields (trade no, authorities, lien, delivery mode, …) kept as a JSON object. */
public static final String OTHER_DETAILS = "otherDetails";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, OTHER_CHARGES, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE,
OTHER_DETAILS, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,203 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* 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.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.service.InvestmentNonSlrService;
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;
private final InvestmentNonSlrService nonSlrService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List investment purchases (holdings)")
public List<InvestmentPurchaseData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
return this.readPlatformService.retrieveAll();
}
@GET
@Path("non-slr-limit")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "RBI non-SLR limits for a purchase", description = "Where a non-SLR purchase of amount (book value) in scriptId "
+ "on dealDate (dd-MM-yyyy, default today) stands against the 10% limits; any of them may be left out.")
public InvestmentNonSlrLimitData nonSlrLimit(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
@QueryParam("amount") @Parameter(description = "amount") final BigDecimal amount) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate date = StringUtils.isBlank(dealDate) ? DateUtils.getBusinessLocalDate()
: LocalDate.parse(dealDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.nonSlrService.limits(date, scriptId, amount);
}
@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;
}
}
@@ -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;
/** 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,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/**
* Bucket Wise Investment: Govt Securities, bonds and FDs held on a date, split by the time left to maturity (up to 1
* year, over 1 to 3, over 3 to 5, over 5 years).
*/
@Getter
@Builder
public class InvestmentBucketReportData {
private final String asOnDate;
private final List<Bucket> buckets;
private final Bucket total;
private final List<Row> rows;
@Getter
@Builder
public static class Bucket {
private final String code;
private final String label;
private final int govtCount;
private final BigDecimal govtFaceValue;
private final BigDecimal govtBookValue;
private final int bondCount;
private final BigDecimal bondFaceValue;
private final BigDecimal bondBookValue;
private final int fdCount;
private final BigDecimal fdPrincipal;
/** Book value of securities and bonds plus FD principal. */
private final BigDecimal totalAmount;
}
@Getter
@Builder
public static class Row {
/** Code of the bucket the holding falls in. */
private final String bucket;
/** GOVT_SECURITY, BOND (Non-SLR and other bonds) or FD. */
private final String instrument;
private final Long refId;
private final String dealNo;
/** Script, or bank and certificate of an FD. */
private final String particulars;
/** HTM / AFS / HFT of a security; blank for an FD. */
private final String category;
private final BigDecimal rate;
private final String maturityDate;
/** Days from the as-on date to maturity; negative when maturity has passed but it is not yet redeemed or closed. */
private final long daysToMaturity;
/** Face value held, or the FD principal. */
private final BigDecimal faceValue;
/** Cost less what sales, amortisation and write-offs took off, or the FD principal. */
private final BigDecimal bookValue;
}
}
@@ -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,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/**
* Due Date Diary: every dated event of the active investments in a period — Govt Security / bond coupons and 31 Mar /
* 30 Sep provisions, maturities, premium amortisation, and FD provisions, payouts and maturities.
*/
@Getter
@Builder
public class InvestmentDiaryData {
private final String fromDate;
private final String toDate;
private final String asOnDate;
private final int pendingCount;
private final int overdueCount;
/** Money expected in the period from coupons, FD payouts and maturities not yet received. */
private final BigDecimal expectedInflow;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
private final String date;
/** COUPON, PROVISION, MATURITY, AMORTISATION (Govt Security / bond); FD_PROVISION, FD_PAYOUT, FD_MATURITY. */
private final String kind;
/** SECURITY or FD — which screen the entry belongs to. */
private final String instrument;
private final Long refId;
private final String dealNo;
/** Script, or bank and certificate of an FD. */
private final String particulars;
private final BigDecimal amount;
/** POSTED, OVERDUE, DUE (today), UPCOMING. */
private final String status;
private final String voucherNo;
}
}
@@ -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,107 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.portfolio.investment.domain.InvestmentFd;
/** An FD placed with another bank; also the shape of a purchase preview (with the limit check). */
@Getter
@Builder
public class InvestmentFdData {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final String dealNo;
private final Long partyId;
private final String partyCode;
private final String partyName;
/** TDS rate the bank deducts on interest; null when it does not deduct TDS. */
private final BigDecimal tdsRate;
private final String certNo;
private final String certDate;
private final String dealDate;
private final BigDecimal principal;
private final BigDecimal intRate;
private final String maturityDate;
private final long days;
private final String payout;
private final String calcType;
private final BigDecimal expectedInterest;
private final BigDecimal expectedMaturityAmount;
/** Interest paid each half year / year for a payout FD. */
private final BigDecimal periodInterest;
private final String fdGlCode;
private final String fdGlName;
private final String interestGlCode;
private final String interestGlName;
private final Long bankGlAccountId;
private final String bankGlCode;
private final String bankGlName;
private final String status;
private final String remarks;
private final String glTransactionId;
private final String voucherNo;
private final String renewedFromDealNo;
private final String closedOnDate;
private final String closureType;
private final BigDecimal closureAmount;
private final BigDecimal closureInterest;
private final BigDecimal closureTds;
private final BigDecimal closurePenal;
private final String closureVoucherNo;
private final String closureGlTransactionId;
private final InvestmentFdLimitData limit;
public static InvestmentFdData from(final InvestmentFd f, final BigDecimal periodInterest) {
return builder().id(f.getId()).dealNo(f.getDealNo()).partyId(f.getParty().getId()).partyCode(f.getParty().getPartyCode())
.partyName(f.getParty().getPartyName())
.tdsRate(f.getParty().isTdsApplicable() ? f.getParty().getTdsRate() : null).certNo(f.getCertNo())
.certDate(format(f.getCertDate()))
.dealDate(format(f.getDealDate())).principal(f.getPrincipal()).intRate(f.getIntRate())
.maturityDate(format(f.getMaturityDate())).days(ChronoUnit.DAYS.between(f.getDealDate(), f.getMaturityDate()))
.payout(f.getPayout()).calcType(f.getCalcType()).expectedInterest(f.getExpectedInterest())
.expectedMaturityAmount(f.getExpectedMaturityAmount()).periodInterest(periodInterest).fdGlCode(code(f.getFdGlAccount()))
.fdGlName(name(f.getFdGlAccount())).interestGlCode(code(f.getInterestGlAccount())).interestGlName(name(f.getInterestGlAccount()))
.bankGlAccountId(f.getBankGlAccount().getId()).bankGlCode(code(f.getBankGlAccount())).bankGlName(name(f.getBankGlAccount()))
.status(f.getStatus()).remarks(f.getRemarks()).glTransactionId(f.getGlTransactionId()).voucherNo(f.getVoucherNo())
.renewedFromDealNo(f.getRenewedFrom() == null ? null : f.getRenewedFrom().getDealNo()).closedOnDate(format(f.getClosedOnDate()))
.closureType(f.getClosureType()).closureAmount(f.getClosureAmount()).closureInterest(f.getClosureInterest())
.closureTds(f.getClosureTds()).closurePenal(f.getClosurePenal()).closureVoucherNo(f.getClosureVoucherNo()).closureGlTransactionId(f.getClosureGlTransactionId()).build();
}
private static String code(final GLAccount gl) {
return gl == null ? null : gl.getGlCode();
}
private static String name(final GLAccount gl) {
return gl == null ? null : gl.getName();
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE);
}
}
@@ -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,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;
/**
* Total Interest Received &amp; Receivable: per Govt Security / bond deal and FD, the interest received in a period and
* the interest earned up to its last day that has not come in yet.
*/
@Getter
@Builder
public class InvestmentInterestReportData {
private final String fromDate;
private final String toDate;
private final BigDecimal totalReceived;
private final BigDecimal totalReceivable;
private final BigDecimal totalProvisioned;
private final BigDecimal total;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
/** SECURITY or FD. */
private final String instrument;
private final Long refId;
private final String dealNo;
/** Script, or bank and certificate of an FD. */
private final String particulars;
private final String status;
/** Face value held, or the FD principal. */
private final BigDecimal principal;
private final BigDecimal rate;
/** Interest that came in during the period (coupons, FD payouts, interest paid at closure), gross of TDS. */
private final BigDecimal received;
/** Interest earned up to the To date and not yet received. */
private final BigDecimal receivable;
/** Part of the receivable already booked by 31 Mar / 30 Sep provisions (Interest Receivable 100603). */
private final BigDecimal provisioned;
private final BigDecimal total;
}
}
@@ -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;
}
}
@@ -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 lombok.Builder;
import lombok.Getter;
/** Where a new non-SLR purchase stands against the RBI limits on non-SLR and unlisted non-SLR investments. */
@Getter
@Builder
public class InvestmentNonSlrLimitData {
/** The 31 March the limits are based on. */
private final String asOnDate;
/** Total deposits as on that date, as entered for the FD limits; empty until someone enters it. */
private final BigDecimal totalDeposits;
/** 10% of total deposits. */
private final BigDecimal nonSlrLimit;
/** Book value of non-SLR investments held or pending settlement, before this deal. */
private final BigDecimal nonSlrOutstanding;
private final BigDecimal nonSlrAfter;
/** Book value of non-SLR investments held on that 31 March. */
private final BigDecimal nonSlrBase;
/** 10% of the non-SLR investments held on that 31 March. */
private final BigDecimal unlistedLimit;
private final BigDecimal unlistedOutstanding;
private final BigDecimal unlistedAfter;
private final BigDecimal newAmount;
/** Whether this deal's security is listed or proposed to be listed; unlisted ones also count against the unlisted limit. */
private final boolean listed;
private final boolean withinLimits;
private final String message;
}
@@ -0,0 +1,71 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* 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;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
@Getter
@Builder
public class InvestmentPartyData {
private final Long id;
private final String partyCode;
private final String bondType;
private final String partyName;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String phone;
private final String fax;
private final String bankType;
private final Long glAccountId;
private final String glCode;
private final String glName;
private final Long interestGlAccountId;
private final String interestGlCode;
private final String interestGlName;
private final boolean tdsApplicable;
private final BigDecimal tdsRate;
private final String rbiCode;
private final BigDecimal limit;
private final String leiNo;
private final String cpBankName;
private final String cpBranchName;
private final String cpAccountNo;
private final String cpIfsc;
private final boolean active;
public static InvestmentPartyData from(final InvestmentParty p) {
return InvestmentPartyData.builder().id(p.getId()).partyCode(p.getPartyCode()).bondType(p.getBondType())
.partyName(p.getPartyName()).address1(p.getAddress1()).address2(p.getAddress2()).address3(p.getAddress3())
.city(p.getCity()).pincode(p.getPincode()).phone(p.getPhone()).fax(p.getFax()).bankType(p.getBankType())
.glAccountId(p.getGlAccount().getId()).glCode(p.getGlAccount().getGlCode()).glName(p.getGlAccount().getName())
.interestGlAccountId(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getId())
.interestGlCode(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getGlCode())
.interestGlName(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getName())
.tdsApplicable(p.isTdsApplicable()).tdsRate(p.getTdsRate()).rbiCode(p.getRbiCode()).limit(p.getExposureLimit())
.leiNo(p.getLeiNo()).cpBankName(p.getCpBankName())
.cpBranchName(p.getCpBranchName()).cpAccountNo(p.getCpAccountNo()).cpIfsc(p.getCpIfsc()).active(p.isActive()).build();
}
}
@@ -0,0 +1,100 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
@Getter
@Builder
public class InvestmentPurchaseData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final String dealNo;
private final String certNo;
private final String bondType;
private final Long scriptId;
private final String scriptCode;
private final String scriptDesc;
private final String isin;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final String dealDate;
private final String dealTime;
private final String settlementType;
private final String settlementDate;
private final BigDecimal faceValue;
private final BigDecimal purchaseRate;
private final BigDecimal units;
private final BigDecimal balanceUnits;
private final BigDecimal brokerage;
private final BigDecimal stampDuty;
private final BigDecimal otherCharges;
private final BigDecimal bookValue;
private final BigDecimal premiumDiscount;
private final BigDecimal intRate;
private final String issueDate;
private final String maturityDate;
private final String lastIntDate;
private final Integer accruedDays;
private final BigDecimal accruedInterest;
private final BigDecimal settlementAmount;
private final BigDecimal ytm;
private final String category;
private final String subType;
private final Long investmentGlAccountId;
private final String investmentGlCode;
private final String investmentGlName;
private final Long crGlAccountId;
private final String crGlCode;
private final String crGlName;
private final String otherDetails;
private final String status;
private final String glTransactionId;
private final String settledOnDate;
public static InvestmentPurchaseData from(final InvestmentPurchase p) {
return InvestmentPurchaseData.builder().id(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()).bondType(p.getBondType())
.scriptId(p.getScript().getId()).scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc())
.isin(p.getScript().getIsin()).partyId(p.getParty().getId()).partyCode(p.getParty().getPartyCode())
.partyName(p.getParty().getPartyName()).dealDate(format(p.getDealDate())).dealTime(p.getDealTime())
.settlementType(p.getSettlementType()).settlementDate(format(p.getSettlementDate())).faceValue(p.getFaceValue())
.purchaseRate(p.getPurchaseRate()).units(p.getUnits()).balanceUnits(p.getBalanceUnits()).brokerage(p.getBrokerage())
.stampDuty(p.getStampDuty()).otherCharges(p.getOtherCharges()).bookValue(p.getBookValue())
.premiumDiscount(p.getPremiumDiscount()).intRate(p.getIntRate()).issueDate(format(p.getIssueDate()))
.maturityDate(format(p.getMaturityDate())).lastIntDate(format(p.getLastIntDate())).accruedDays(p.getAccruedDays())
.accruedInterest(p.getAccruedInterest()).settlementAmount(p.getSettlementAmount()).ytm(p.getYtm()).category(p.getCategory())
.subType(p.getSubType()).investmentGlAccountId(p.getInvestmentGlAccount().getId())
.investmentGlCode(p.getInvestmentGlAccount().getGlCode()).investmentGlName(p.getInvestmentGlAccount().getName())
.crGlAccountId(p.getCrGlAccount().getId()).crGlCode(p.getCrGlAccount().getGlCode()).crGlName(p.getCrGlAccount().getName())
.otherDetails(p.getOtherDetails()).status(p.getStatus()).glTransactionId(p.getGlTransactionId())
.settledOnDate(format(p.getSettledOnDate())).build();
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -0,0 +1,102 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
/** A sale or redemption of a holding — also the shape of a quote before it is saved. */
@Getter
@Builder
public class InvestmentSaleData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String bondType;
private final String scriptCode;
private final String scriptDesc;
private final String saleType;
private final Long partyId;
private final String partyName;
private final String dealDate;
private final String dealTime;
private final String settlementType;
private final String settlementDate;
private final BigDecimal units;
private final BigDecimal faceValue;
private final BigDecimal saleRate;
private final BigDecimal consideration;
private final Integer accruedDays;
private final BigDecimal accruedInterest;
private final BigDecimal receivableCleared;
/** Interest from the buyer not yet booked: accrued interest less the receivable cleared. */
private final BigDecimal interestIncome;
private final BigDecimal brokerage;
private final BigDecimal bookValueSold;
private final BigDecimal premiumCleared;
private final BigDecimal profitLoss;
private final BigDecimal netAmount;
private final Long bankGlAccountId;
private final String bankGlCode;
private final String bankGlName;
private final String investmentGlCode;
/** GL the premium share is credited to: 100707, or the investment GL for deals settled before it was used. */
private final String premiumGlCode;
private final String sanctionAuthority;
private final String remarks;
private final String status;
private final String glTransactionId;
private final String voucherNo;
private final String settledOnDate;
public static InvestmentSaleData from(final InvestmentSale s) {
return builder().id(s.getId()).purchaseId(s.getPurchase().getId()).dealNo(s.getPurchase().getDealNo())
.certNo(s.getPurchase().getCertNo()).bondType(s.getPurchase().getBondType())
.scriptCode(s.getPurchase().getScript().getScriptCode()).scriptDesc(s.getPurchase().getScript().getScriptDesc())
.saleType(s.getSaleType()).partyId(s.getParty() == null ? null : s.getParty().getId())
.partyName(s.getParty() == null ? null : s.getParty().getPartyName()).dealDate(format(s.getDealDate()))
.dealTime(s.getDealTime()).settlementType(s.getSettlementType()).settlementDate(format(s.getSettlementDate()))
.units(s.getUnits()).faceValue(s.getFaceValue()).saleRate(s.getSaleRate()).consideration(s.getConsideration())
.accruedDays(s.getAccruedDays()).accruedInterest(s.getAccruedInterest()).receivableCleared(s.getReceivableCleared())
.interestIncome(zero(s.getAccruedInterest()).subtract(zero(s.getReceivableCleared()))).brokerage(s.getBrokerage())
.bookValueSold(s.getBookValueSold()).premiumCleared(s.getPremiumCleared()).profitLoss(s.getProfitLoss()).netAmount(s.getNetAmount())
.bankGlAccountId(s.getBankGlAccount().getId()).bankGlCode(s.getBankGlAccount().getGlCode())
.bankGlName(s.getBankGlAccount().getName()).investmentGlCode(s.getPurchase().getInvestmentGlAccount().getGlCode())
.premiumGlCode(s.getPurchase().getPremiumGlAccount() != null ? s.getPurchase().getPremiumGlAccount().getGlCode()
: s.getPurchase().getInvestmentGlAccount().getGlCode())
.sanctionAuthority(s.getSanctionAuthority()).remarks(s.getRemarks()).status(s.getStatus())
.glTransactionId(s.getGlTransactionId()).voucherNo(s.getVoucherNo()).settledOnDate(format(s.getSettledOnDate())).build();
}
private static BigDecimal zero(final BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -0,0 +1,76 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
@Getter
@Builder
public class InvestmentScriptData {
/** Dates go out as DD-MM-YYYY, the format the Investment screens use throughout. */
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final String scriptCode;
private final String bondType;
private final String slrType;
private final String scriptDesc;
private final Long glAccountId;
private final String glCode;
private final String glName;
private final String isin;
private final BigDecimal faceValuePerUnit;
private final BigDecimal intRate;
private final String issueDate;
private final String maturityDate;
private final String companyName;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String phone;
private final String fax;
private final String corpCode;
private final String rating;
private final String ratingAgency;
private final boolean listed;
private final boolean active;
public static InvestmentScriptData from(final InvestmentScript s) {
return InvestmentScriptData.builder().id(s.getId()).scriptCode(s.getScriptCode()).bondType(s.getBondType())
.slrType(s.getSlrType()).scriptDesc(s.getScriptDesc()).glAccountId(s.getGlAccount().getId())
.glCode(s.getGlAccount().getGlCode()).glName(s.getGlAccount().getName()).isin(s.getIsin())
.faceValuePerUnit(s.getFaceValuePerUnit()).intRate(s.getIntRate()).issueDate(format(s.getIssueDate()))
.maturityDate(format(s.getMaturityDate())).companyName(s.getCompanyName()).address1(s.getAddress1())
.address2(s.getAddress2()).address3(s.getAddress3()).city(s.getCity()).pincode(s.getPincode()).phone(s.getPhone())
.fax(s.getFax()).corpCode(s.getCorpCode()).rating(s.getRating()).ratingAgency(s.getRatingAgency()).listed(s.isListed())
.active(s.isActive()).build();
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransfer;
/** A GOI Category Transfer of a holding, for the transaction log. */
@Getter
@Builder
public class InvestmentSecurityTransferData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String bondType;
private final String fromScriptCode;
private final String fromScriptDesc;
private final String toScriptCode;
private final String toScriptDesc;
private final String fromGlCode;
private final String toGlCode;
private final String toGlName;
private final String transferDate;
private final BigDecimal units;
private final BigDecimal bookValue;
private final String remarks;
private final String glTransactionId;
private final String voucherNo;
public static InvestmentSecurityTransferData from(final InvestmentSecurityTransfer t) {
return builder().id(t.getId()).purchaseId(t.getPurchase().getId()).dealNo(t.getPurchase().getDealNo())
.certNo(t.getPurchase().getCertNo()).bondType(t.getPurchase().getBondType()).fromScriptCode(t.getFromScript().getScriptCode())
.fromScriptDesc(t.getFromScript().getScriptDesc()).toScriptCode(t.getToScript().getScriptCode())
.toScriptDesc(t.getToScript().getScriptDesc()).fromGlCode(t.getFromGlAccount().getGlCode())
.toGlCode(t.getToGlAccount().getGlCode()).toGlName(t.getToGlAccount().getName())
.transferDate(t.getTransferDate().format(DATE_FORMAT)).units(t.getUnits()).bookValue(t.getBookValue())
.remarks(t.getRemarks()).glTransactionId(t.getGlTransactionId()).voucherNo(t.getVoucherNo()).build();
}
}
@@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Daily Script Master: the market rate of a script for one day, as the bank's existing CBS keeps it. Purchase Entry
* takes its rate from here (Jitendra Sir). Close rate is mandatory; open, high and low are optional.
*/
@Entity
@Table(name = "m_investment_daily_rate")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentDailyRate extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "script_id", nullable = false)
private InvestmentScript script;
@Column(name = "rate_date", nullable = false)
private LocalDate rateDate;
@Column(name = "open_rate", scale = 6, precision = 19)
private BigDecimal openRate;
@Column(name = "high_rate", scale = 6, precision = 19)
private BigDecimal highRate;
@Column(name = "low_rate", scale = 6, precision = 19)
private BigDecimal lowRate;
@Column(name = "close_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal closeRate;
}
@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentDailyRateRepository extends JpaRepository<InvestmentDailyRate, Long> {
boolean existsByScriptIdAndRateDate(Long scriptId, LocalDate rateDate);
/** The newest rate of a script on or before a date. */
Optional<InvestmentDailyRate> findFirstByScriptIdAndRateDateLessThanEqualOrderByRateDateDesc(Long scriptId, LocalDate onDate);
List<InvestmentDailyRate> findByScriptIdOrderByRateDateDesc(Long scriptId);
}
@@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/** The bank's total deposits as on a 31 March: the base for the RBI limits on deposits placed with other banks. */
@Entity
@Table(name = "m_investment_deposit_base")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentDepositBase extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "as_on_date", nullable = false, unique = true)
private LocalDate asOnDate;
@Column(name = "total_deposits", scale = 6, precision = 19, nullable = false)
private BigDecimal totalDeposits;
@Column(name = "remarks", length = 500)
private String remarks;
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import java.time.LocalDate;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentDepositBaseRepository extends JpaRepository<InvestmentDepositBase, Long> {
Optional<InvestmentDepositBase> findByAsOnDate(LocalDate asOnDate);
}
@@ -0,0 +1,135 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/** A fixed deposit the bank has placed with another bank, from placement to maturity, premature closure or renewal. */
@Entity
@Table(name = "m_investment_fd")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentFd extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "deal_no", nullable = false, length = 20, unique = true)
private String dealNo;
@ManyToOne
@JoinColumn(name = "party_id", nullable = false)
private InvestmentParty party;
/** The bank's FD GL (from the party), debited when the money is placed. */
@ManyToOne
@JoinColumn(name = "fd_gl_account_id", nullable = false)
private GLAccount fdGlAccount;
@ManyToOne
@JoinColumn(name = "interest_gl_account_id", nullable = false)
private GLAccount interestGlAccount;
/** Certificate (receipt) number issued by the other bank. */
@Column(name = "cert_no", nullable = false, length = 50)
private String certNo;
@Column(name = "cert_date")
private LocalDate certDate;
@Column(name = "deal_date", nullable = false)
private LocalDate dealDate;
@Column(name = "principal", scale = 6, precision = 19, nullable = false)
private BigDecimal principal;
@Column(name = "int_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal intRate;
@Column(name = "maturity_date", nullable = false)
private LocalDate maturityDate;
@Column(name = "payout", nullable = false, length = 20)
private String payout;
@Column(name = "calc_type", nullable = false, length = 20)
private String calcType;
@Column(name = "expected_interest", scale = 6, precision = 19)
private BigDecimal expectedInterest;
@Column(name = "expected_maturity_amount", scale = 6, precision = 19)
private BigDecimal expectedMaturityAmount;
/** Our bank account the money was paid from. */
@ManyToOne
@JoinColumn(name = "bank_gl_account_id", nullable = false)
private GLAccount bankGlAccount;
/** The FD this one renews, if any. */
@ManyToOne
@JoinColumn(name = "renewed_from_id")
private InvestmentFd renewedFrom;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "voucher_no", length = 30)
private String voucherNo;
@Column(name = "closed_on_date")
private LocalDate closedOnDate;
/** MATURITY, PREMATURE or RENEWAL. */
@Column(name = "closure_type", length = 20)
private String closureType;
@Column(name = "closure_amount", scale = 6, precision = 19)
private BigDecimal closureAmount;
@Column(name = "closure_interest", scale = 6, precision = 19)
private BigDecimal closureInterest;
@Column(name = "closure_tds", scale = 6, precision = 19)
private BigDecimal closureTds;
@Column(name = "closure_penal", scale = 6, precision = 19)
private BigDecimal closurePenal;
@Column(name = "closure_gl_transaction_id", length = 50)
private String closureGlTransactionId;
@Column(name = "closure_voucher_no", length = 30)
private String closureVoucherNo;
}
@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/** A posted interest line of an FD: a 31 Mar / 30 Sep provision, or a half-yearly / yearly payout. */
@Entity
@Table(name = "m_investment_fd_interest")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentFdInterest extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "fd_id", nullable = false)
private InvestmentFd fd;
/** PROVISION or PAYOUT. */
@Column(name = "line_type", nullable = false, length = 20)
private String lineType;
@Column(name = "int_date", nullable = false)
private LocalDate intDate;
@Column(name = "from_date")
private LocalDate fromDate;
@Column(name = "to_date")
private LocalDate toDate;
@Column(name = "days")
private Integer days;
@Column(name = "gross_interest", scale = 6, precision = 19)
private BigDecimal grossInterest;
/** For a payout: the part already provisioned, cleared from interest receivable. */
@Column(name = "already_booked", scale = 6, precision = 19)
private BigDecimal alreadyBooked;
@Column(name = "tds", scale = 6, precision = 19)
private BigDecimal tds;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
@ManyToOne
@JoinColumn(name = "bank_gl_account_id")
private GLAccount bankGlAccount;
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "voucher_no", length = 30)
private String voucherNo;
@Column(name = "posted_on_date")
private LocalDate postedOnDate;
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentFdInterestRepository extends JpaRepository<InvestmentFdInterest, Long> {
List<InvestmentFdInterest> findByFdIdOrderByIntDateAscIdAsc(Long fdId);
}
@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface InvestmentFdRepository extends JpaRepository<InvestmentFd, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select f from InvestmentFd f where f.id = :fdId")
Optional<InvestmentFd> findByIdWithLock(@Param("fdId") Long fdId);
List<InvestmentFd> findByStatusOrderByIdAsc(String status);
boolean existsByPartyIdAndCertNoIgnoreCase(Long partyId, String certNo);
}
@@ -0,0 +1,84 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/** One interest schedule line of a holding (coupon received or closing provision) that has been posted to GL. */
@Entity
@Table(name = "m_investment_interest_posting")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentInterestPosting extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "purchase_id", nullable = false)
private InvestmentPurchase purchase;
/** COUPON or PROVISION. */
@Column(name = "line_type", nullable = false, length = 20)
private String lineType;
@Column(name = "int_date", nullable = false)
private LocalDate intDate;
@Column(name = "from_date")
private LocalDate fromDate;
@Column(name = "to_date")
private LocalDate toDate;
@Column(name = "days")
private Integer days;
@Column(name = "gross_interest", scale = 6, precision = 19)
private BigDecimal grossInterest;
@Column(name = "already_booked", scale = 6, precision = 19)
private BigDecimal alreadyBooked;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
/** Bank the coupon was received into (coupon lines only). */
@ManyToOne
@JoinColumn(name = "bank_gl_account_id")
private GLAccount bankGlAccount;
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "voucher_no", length = 30)
private String voucherNo;
@Column(name = "posted_on_date")
private LocalDate postedOnDate;
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentInterestPostingRepository extends JpaRepository<InvestmentInterestPosting, Long> {
List<InvestmentInterestPosting> findByPurchaseIdOrderByIntDateAsc(Long purchaseId);
}
@@ -0,0 +1,118 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Bank / Party Master — the counterparty an investment is bought from or placed with (bank, RBI, issuer, AMC, broker),
* following the bank's existing CBS Bank / Party Master.
*/
@Entity
@Table(name = "m_investment_party")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentParty extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "party_code", nullable = false, length = 20, unique = true)
private String partyCode;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
@Column(name = "party_name", nullable = false, length = 150)
private String partyName;
@Column(name = "address1", length = 150)
private String address1;
@Column(name = "address2", length = 150)
private String address2;
@Column(name = "address3", length = 150)
private String address3;
@Column(name = "city", length = 100)
private String city;
@Column(name = "pincode", length = 10)
private String pincode;
@Column(name = "phone", length = 20)
private String phone;
@Column(name = "fax", length = 20)
private String fax;
@Column(name = "bank_type", length = 60)
private String bankType;
@ManyToOne
@JoinColumn(name = "gl_account_id", nullable = false)
private GLAccount glAccount;
/** Interest income GL of the FDs placed with this bank (Bond Type FD only). */
@ManyToOne
@JoinColumn(name = "interest_gl_account_id")
private GLAccount interestGlAccount;
/** Whether this bank deducts TDS from the interest on FDs placed with it (some banks do, some do not). */
@Column(name = "tds_applicable", nullable = false)
private boolean tdsApplicable;
/** TDS rate in per cent, set only when the bank deducts TDS. */
@Column(name = "tds_rate", scale = 4, precision = 9)
private BigDecimal tdsRate;
@Column(name = "rbi_code", length = 30)
private String rbiCode;
@Column(name = "exposure_limit", scale = 6, precision = 19)
private BigDecimal exposureLimit;
@Column(name = "lei_no", length = 30)
private String leiNo;
@Column(name = "cp_bank_name", length = 150)
private String cpBankName;
@Column(name = "cp_branch_name", length = 150)
private String cpBranchName;
@Column(name = "cp_account_no", length = 40)
private String cpAccountNo;
@Column(name = "cp_ifsc", length = 11)
private String cpIfsc;
/** An inactive party is kept for history but not offered for new deals. */
@Column(name = "is_active", nullable = false)
private boolean active = true;
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentPartyRepository extends JpaRepository<InvestmentParty, Long> {
/** Locks the bank so two FDs with it are checked against the 5% limit one after the other. */
@org.springframework.data.jpa.repository.Lock(value = jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@org.springframework.data.jpa.repository.Query("select p from InvestmentParty p where p.id = :partyId")
java.util.Optional<InvestmentParty> findByIdWithLock(@org.springframework.data.repository.query.Param("partyId") Long partyId);}
@@ -0,0 +1,163 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Investment Purchase Entry — one deal (as in the bank's existing CBS "Govt.Security/Bond Entry"). Once saved it is the
* holding that interest, sale and redemption later work against; {@link #balanceUnits} tracks what is still held. Every
* derived figure (units, book value, accrued interest, settlement amount) is computed by the backend.
*/
@Entity
@Table(name = "m_investment_purchase")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom<Long> {
/** Financial-year deal number, e.g. 202627000001 for the first deal of FY 2026-27. */
@Column(name = "deal_no", nullable = false, length = 20, unique = true)
private String dealNo;
/** Certificate number — system-generated running number. */
@Column(name = "cert_no", nullable = false, length = 20, unique = true)
private String certNo;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
@ManyToOne
@JoinColumn(name = "script_id", nullable = false)
private InvestmentScript script;
@ManyToOne
@JoinColumn(name = "party_id", nullable = false)
private InvestmentParty party;
@Column(name = "deal_date", nullable = false)
private LocalDate dealDate;
@Column(name = "deal_time", length = 8)
private String dealTime;
@Column(name = "settlement_type", nullable = false, length = 5)
private String settlementType;
@Column(name = "settlement_date", nullable = false)
private LocalDate settlementDate;
@Column(name = "face_value", scale = 6, precision = 19, nullable = false)
private BigDecimal faceValue;
/** Price per ₹100 face value (Govt Security / Bond); NAV for a mutual fund. */
@Column(name = "purchase_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal purchaseRate;
@Column(name = "units", scale = 6, precision = 19, nullable = false)
private BigDecimal units;
@Column(name = "balance_units", scale = 6, precision = 19, nullable = false)
private BigDecimal balanceUnits;
@Column(name = "brokerage", scale = 6, precision = 19)
private BigDecimal brokerage;
@Column(name = "stamp_duty", scale = 6, precision = 19)
private BigDecimal stampDuty;
@Column(name = "other_charges", scale = 6, precision = 19)
private BigDecimal otherCharges;
@Column(name = "book_value", scale = 6, precision = 19, nullable = false)
private BigDecimal bookValue;
@Column(name = "premium_discount", scale = 6, precision = 19)
private BigDecimal premiumDiscount;
@Column(name = "int_rate", scale = 4, precision = 9)
private BigDecimal intRate;
@Column(name = "issue_date")
private LocalDate issueDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "last_int_date")
private LocalDate lastIntDate;
@Column(name = "accrued_days")
private Integer accruedDays;
/** Interest paid to the seller from the last coupon date up to the deal date (30/360). */
@Column(name = "accrued_interest", scale = 6, precision = 19)
private BigDecimal accruedInterest;
@Column(name = "settlement_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal settlementAmount;
@Column(name = "ytm", scale = 4, precision = 9)
private BigDecimal ytm;
@Column(name = "category", nullable = false, length = 40)
private String category;
@Column(name = "sub_type", length = 40)
private String subType;
/** Investment GL the holding is booked to (from the script). */
@ManyToOne
@JoinColumn(name = "investment_gl_account_id", nullable = false)
private GLAccount investmentGlAccount;
/** Bank GL the purchase is paid from ("CR. To A/c"). */
@ManyToOne
@JoinColumn(name = "cr_gl_account_id", nullable = false)
private GLAccount crGlAccount;
/** GL holding the premium paid over face value (100707) until it is amortised; empty when it sits in the investment GL. */
@ManyToOne
@JoinColumn(name = "premium_gl_account_id")
private GLAccount premiumGlAccount;
@Column(name = "other_details", length = 4000)
private String otherDetails;
@Column(name = "status", nullable = false, length = 20)
private String status;
/** Journal entry transaction posted when the deal was settled. */
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "settled_on_date")
private LocalDate settledOnDate;
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface InvestmentPurchaseRepository extends JpaRepository<InvestmentPurchase, Long> {
/** Locks the deal row so two postings on one deal (double click, two users) run one after the other. */
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select p from InvestmentPurchase p where p.id = :purchaseId")
Optional<InvestmentPurchase> findByIdWithLock(@Param("purchaseId") Long purchaseId);
boolean existsByScriptIdAndStatusNot(Long scriptId, String status);
}
@@ -0,0 +1,132 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/** A sale of some or all units of a holding before maturity (SELL), or its repayment at maturity (REDEEM). */
@Entity
@Table(name = "m_investment_sale")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentSale extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "purchase_id", nullable = false)
private InvestmentPurchase purchase;
/** SELL, REDEEM, WRITEOFF or AMORTISE. */
@Column(name = "sale_type", nullable = false, length = 10)
private String saleType;
/** Buyer of a sale; empty for a redemption. */
@ManyToOne
@JoinColumn(name = "party_id")
private InvestmentParty party;
@Column(name = "deal_date", nullable = false)
private LocalDate dealDate;
@Column(name = "deal_time", length = 8)
private String dealTime;
@Column(name = "settlement_type", length = 5)
private String settlementType;
@Column(name = "settlement_date", nullable = false)
private LocalDate settlementDate;
@Column(name = "units", scale = 6, precision = 19, nullable = false)
private BigDecimal units;
@Column(name = "face_value", scale = 6, precision = 19, nullable = false)
private BigDecimal faceValue;
/** Price per ₹100 of face value; 100 for a redemption. */
@Column(name = "sale_rate", scale = 6, precision = 19)
private BigDecimal saleRate;
/** Price received for the units, without interest. */
@Column(name = "consideration", scale = 6, precision = 19, nullable = false)
private BigDecimal consideration;
@Column(name = "accrued_days")
private Integer accruedDays;
/** Interest the buyer pays us for the days since the last coupon. */
@Column(name = "accrued_interest", scale = 6, precision = 19)
private BigDecimal accruedInterest;
/** Part of that interest already booked by a closing provision, cleared from interest receivable. */
@Column(name = "receivable_cleared", scale = 6, precision = 19)
private BigDecimal receivableCleared;
@Column(name = "brokerage", scale = 6, precision = 19)
private BigDecimal brokerage;
/** Cost of the units sold, taken off the investment GL. */
@Column(name = "book_value_sold", scale = 6, precision = 19, nullable = false)
private BigDecimal bookValueSold;
/** Part of the book value taken off that was unamortised premium (credited to the premium GL). */
@Column(name = "premium_cleared", scale = 6, precision = 19)
private BigDecimal premiumCleared;
/** Consideration less cost: positive is profit, negative is loss. */
@Column(name = "profit_loss", scale = 6, precision = 19, nullable = false)
private BigDecimal profitLoss;
/** Money into the bank: consideration plus interest, less brokerage. */
@Column(name = "net_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal netAmount;
@ManyToOne
@JoinColumn(name = "bank_gl_account_id", nullable = false)
private GLAccount bankGlAccount;
@Column(name = "sanction_authority", length = 100)
private String sanctionAuthority;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "voucher_no", length = 30)
private String voucherNo;
@Column(name = "settled_on_date")
private LocalDate settledOnDate;
}
@@ -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.domain;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface InvestmentSaleRepository extends JpaRepository<InvestmentSale, Long> {
List<InvestmentSale> findByPurchaseIdOrderByIdAsc(Long purchaseId);
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from InvestmentSale s where s.id = :saleId")
Optional<InvestmentSale> findByIdWithLock(@Param("saleId") Long saleId);
/** The deal of a sale, without loading either, so the deal can be locked first. */
@Query("select s.purchase.id from InvestmentSale s where s.id = :saleId")
Optional<Long> findPurchaseIdBySaleId(@Param("saleId") Long saleId);
}
@@ -0,0 +1,118 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Script Master — what is being bought (a Govt Security, bond, mutual fund scheme, share, …), following the bank's
* existing CBS Script Master. A purchase picks these terms up instead of the dealer re-typing them.
*/
@Entity
@Table(name = "m_investment_script")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentScript extends AbstractAuditableWithUTCDateTimeCustom<Long> {
/** System-generated: bond-type letter + running number, e.g. G00001. */
@Column(name = "script_code", nullable = false, length = 20, unique = true)
private String scriptCode;
@Column(name = "bond_type", nullable = false, length = 20)
private String bondType;
/** GOI / STATE_GOVT / TREASURY / NON_SLR. */
@Column(name = "slr_type", length = 20)
private String slrType;
@Column(name = "script_desc", nullable = false, length = 150)
private String scriptDesc;
@ManyToOne
@JoinColumn(name = "gl_account_id", nullable = false)
private GLAccount glAccount;
@Column(name = "isin", length = 20)
private String isin;
@Column(name = "face_value_per_unit", scale = 6, precision = 19, nullable = false)
private BigDecimal faceValuePerUnit;
@Column(name = "int_rate", scale = 4, precision = 9)
private BigDecimal intRate;
@Column(name = "issue_date")
private LocalDate issueDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "company_name", length = 150)
private String companyName;
@Column(name = "address1", length = 150)
private String address1;
@Column(name = "address2", length = 150)
private String address2;
@Column(name = "address3", length = 150)
private String address3;
@Column(name = "city", length = 100)
private String city;
@Column(name = "pincode", length = 10)
private String pincode;
@Column(name = "phone", length = 20)
private String phone;
@Column(name = "fax", length = 20)
private String fax;
@Column(name = "corp_code", length = 30)
private String corpCode;
/** Credit rating, long-term (AAA … D) for bonds or short-term (A1+ … A4) for CPs / CDs; UNRATED when none. */
@Column(name = "rating", length = 10)
private String rating;
@Column(name = "rating_agency", length = 30)
private String ratingAgency;
/** Listed on an exchange, or proposed to be listed (RBI counts those as listed when bought). */
@Column(name = "is_listed", nullable = false)
private boolean listed = true;
@Column(name = "is_active", nullable = false)
private boolean active = true;
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentScriptRepository extends JpaRepository<InvestmentScript, Long> {
List<InvestmentScript> findByIsinIgnoreCase(String isin);
}
@@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/** A holding moved from one government security script to another of the same terms (GOI Category Transfer). */
@Entity
@Table(name = "m_investment_security_transfer")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentSecurityTransfer extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "purchase_id", nullable = false)
private InvestmentPurchase purchase;
@ManyToOne
@JoinColumn(name = "from_script_id", nullable = false)
private InvestmentScript fromScript;
@ManyToOne
@JoinColumn(name = "to_script_id", nullable = false)
private InvestmentScript toScript;
@ManyToOne
@JoinColumn(name = "from_gl_account_id", nullable = false)
private GLAccount fromGlAccount;
@ManyToOne
@JoinColumn(name = "to_gl_account_id", nullable = false)
private GLAccount toGlAccount;
@Column(name = "transfer_date", nullable = false)
private LocalDate transferDate;
@Column(name = "units", scale = 6, precision = 19, nullable = false)
private BigDecimal units;
/** Book value carried over; moved between GLs only when the two scripts map to different GLs. */
@Column(name = "book_value", scale = 6, precision = 19, nullable = false)
private BigDecimal bookValue;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "voucher_no", length = 30)
private String voucherNo;
}
@@ -0,0 +1,23 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentSecurityTransferRepository extends JpaRepository<InvestmentSecurityTransfer, Long> {}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentDailyRateNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentDailyRateNotFoundException(final Long id) {
super("error.msg.investmentdailyrate.id.invalid", "Daily script rate with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentPartyNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentPartyNotFoundException(final Long id) {
super("error.msg.investmentparty.id.invalid", "Investment party with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentPurchaseNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentPurchaseNotFoundException(final Long id) {
super("error.msg.investmentpurchase.id.invalid", "Investment purchase with identifier " + id + " does not exist", id);
}
}

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