Compare commits

..
14 Commits
Author SHA1 Message Date
keshav d7d22382a5 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into uate
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 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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 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 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
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-07-31 15:20:07 +05:30
keshav 8622dcaf90 static update
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 - 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-07-31 15:11:23 +05:30
niteshandClaude Sonnet 5 7cdc25322e fix(aml): resolve ambiguous order_position subquery breaking tenant migration/startup
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 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 - 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 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-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-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-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
0434_seed_aml_transaction_rules.xml resolved source_client_risk_rule_id via
`SELECT id FROM m_client_risk_rule WHERE order_position = N`, assuming
order_position was unique. It isn't: 0430_seed_aml_compliance_rules.xml seeds
its own 221 rows with order_position 1-235, independently of the older
0428_add_client_risk_rules.xml rows, and several values collide (e.g. both
insert a row at order_position 10 and 30). Every affected subquery returned
more than one row, aborting the whole tenant's Liquibase run and app startup.

aml_check_status is populated only on 0430's rows (0428's predate that
column and are NULL there), so pairing it with order_position uniquely
targets the intended row without touching either seed changeset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 14:27:08 +05:30
nitesh a4c3dbb6c4 Merge remote-tracking branch 'origin/dev' into uate
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (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 Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (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 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 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (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 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-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
2026-07-31 13:58:55 +05:30
niteshandClaude Sonnet 5 dd8445de06 fix(accounting): resolve Sponsor DD Payable SBI GL account parent by gl_code, update default tenant DB password
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-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 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 - 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
The 0345 changelog hardcoded the "OTHER LIABILITY" parent row's numeric id
(22), which only matched the default tenant's auto-increment history and
broke the FK insert (and the whole Liquibase run / app startup) on tenants
seeded differently, e.g. saga. Now resolves parent_id via a SELECT on
gl_code='9' and marks the changeSet runOnChange so it safely re-validates
on tenants that already ran the old version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:56:04 +05:30
nitesh 9345f2294c Merge remote-tracking branch 'origin/uate' into uate
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 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 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 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 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
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 - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
2026-07-31 13:54:07 +05:30
niteshandClaude Sonnet 5 190cd92a85 fix(accounting): resolve Sponsor DD Payable SBI GL account parent by gl_code, update default tenant DB password
The 0345 changelog hardcoded the "OTHER LIABILITY" parent row's numeric id
(22), which only matched the default tenant's auto-increment history and
broke the FK insert (and the whole Liquibase run / app startup) on tenants
seeded differently, e.g. saga. Now resolves parent_id via a SELECT on
gl_code='9' and marks the changeSet runOnChange so it safely re-validates
on tenants that already ran the old version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 13:53:27 +05:30
Anshul Rathore 81832b6bb7 feat: add Avro schema definitions for Fineract data entities and update configuration
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (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 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 - 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 - 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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-07-31 13:27:40 +05:30
Anshul Rathore 0bd5296519 chore: update JVM memory configuration and add local API integration scripts
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-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 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 - 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-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
2026-07-31 12:41:35 +05:30
Anshul Rathore 17e0dab435 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev 2026-07-31 12:41:22 +05:30
Anshul Rathore 67810bfaea Add Welcome Kit Dispatch feature and Share Account summary API 2026-07-31 11:51:57 +05:30
keshav 2022dc5241 kyc risk and AML & Compliance Checks automate on client creation
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 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 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 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 (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-07-31 11:02:31 +05:30
nitesh b1cfa996b4 feat(clients): fromDate/toDate submittedon_date range filter on GET /clients
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (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 Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (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 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 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 (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 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 - 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 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
Backs the Client Approval tab's Today/Yesterday quick filters server-side.
offset/limit/displayName/orderBy/sortOrder/status already existed on this
endpoint; added fromDate/toDate (yyyy-MM-dd, inclusive) alongside them,
filtering on c.submittedon_date, following the same ::date-cast convention
already used by ClientPendingEditReadPlatformServiceImpl for the
equivalent pending-edits filter. Updated the one other caller
(SelfClientsApiResource) to pass null/null.
2026-07-30 10:33:48 +05:30
nitesh 02541eed6c feat(sanctions): expose the loaded list's data-currency date on every screening result
Screening only ever told a caller whether a name matched and when the
check ran — not which dated snapshot of the UN Security Council list it
ran against. Added SanctionsEntryRepository.findListLastLoadedOn()
(MAX(createdDate) over active entries) and threaded it through
SanctionsScreeningResult as listDataAsOf, so the frontend can show "List
Data As Of: <date>" alongside "Screened On".
2026-07-30 10:33:33 +05:30
112 changed files with 12433 additions and 36 deletions
+1
View File
@@ -0,0 +1 @@
{"username":"mifos", "password":"password"}
+18
View File
@@ -0,0 +1,18 @@
const https = require('https');
const agent = new https.Agent({ rejectUnauthorized: false });
fetch('https://localhost:8443/fineract-provider/api/v1/authentication?username=mifos&password=password&tenantIdentifier=default', {
method: 'POST',
agent: agent
}).then(res => res.json()).then(data => {
const token = data.base64EncodedAuthenticationKey;
return fetch('https://localhost:8443/fineract-provider/api/v1/clients/9/accounts?tenantIdentifier=default', {
headers: {
'Authorization': 'Basic ' + token,
'Fineract-Platform-TenantId': 'default'
},
agent: agent
});
}).then(res => res.json()).then(data => {
console.log(JSON.stringify(data, null, 2));
}).catch(console.error);
@@ -65,6 +65,8 @@ public enum JobName {
NEFT_BATCH_PROCESS("NEFT Batch Process"), //
VERIFICATION_TOKEN_CLEANUP("Verification Token Cleanup"), //
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
; //
private final String name;
@@ -0,0 +1,160 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.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.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One row per {@link AmlTransactionRule} hit on a live transaction. Raised regardless of the
* client's current KYC risk category - alerting is never suppressed by a client being Low/Medium
* risk. First reviewed by an AML Officer (whitelist or escalate); an ESCALATED alert spawns an
* {@link AmlStrCase} for the Principal Officer.
*/
@Entity
@Table(name = "m_aml_alert")
public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "transaction_type", nullable = false, length = 20)
private String transactionType;
@Column(name = "transaction_id", nullable = false)
private Long transactionId;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@ManyToOne
@JoinColumn(name = "rule_id", nullable = false)
private AmlTransactionRule rule;
@Column(name = "amount", nullable = false, precision = 19, scale = 6)
private BigDecimal amount;
@Column(name = "currency_code", length = 3)
private String currencyCode;
@Column(name = "raised_on", nullable = false)
private LocalDateTime raisedOn;
@Column(name = "status", nullable = false, length = 20)
private String status;
@ManyToOne
@JoinColumn(name = "reviewed_by")
private AppUser reviewedBy;
@Column(name = "reviewed_on")
private LocalDateTime reviewedOn;
@Column(name = "review_notes", length = 1000)
private String reviewNotes;
@Column(name = "disposition", length = 20)
private String disposition;
protected AmlAlert() {}
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
BigDecimal amount, String currencyCode, LocalDateTime raisedOn) {
final AmlAlert alert = new AmlAlert();
alert.transactionType = transactionType.name();
alert.transactionId = transactionId;
alert.client = client;
alert.rule = rule;
alert.amount = amount;
alert.currencyCode = currencyCode;
alert.raisedOn = raisedOn;
alert.status = AmlAlertStatus.RAISED.name();
return alert;
}
public void whitelist(AppUser reviewer, String notes) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.disposition = AmlAlertDisposition.WHITELIST.name();
this.status = AmlAlertStatus.WHITELISTED.name();
}
public void escalate(AppUser reviewer, String notes) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.disposition = AmlAlertDisposition.ESCALATE.name();
this.status = AmlAlertStatus.ESCALATED.name();
}
public Long getTransactionId() {
return transactionId;
}
public AmlTransactionType getTransactionType() {
return AmlTransactionType.valueOf(transactionType);
}
public Client getClient() {
return client;
}
public AmlTransactionRule getRule() {
return rule;
}
public BigDecimal getAmount() {
return amount;
}
public String getCurrencyCode() {
return currencyCode;
}
public LocalDateTime getRaisedOn() {
return raisedOn;
}
public AmlAlertStatus getStatus() {
return AmlAlertStatus.valueOf(status);
}
public AppUser getReviewedBy() {
return reviewedBy;
}
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
public String getReviewNotes() {
return reviewNotes;
}
public AmlAlertDisposition getDisposition() {
return disposition == null ? null : AmlAlertDisposition.valueOf(disposition);
}
}
@@ -0,0 +1,25 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
/** The AML Officer's first-level disposition of an {@link AmlAlert}. */
public enum AmlAlertDisposition {
WHITELIST, ESCALATE
}
@@ -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.aml.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
List<AmlAlert> findByStatusOrderByRaisedOnAsc(String status);
@Query("SELECT COALESCE(SUM(a.amount), 0) FROM AmlAlert a WHERE a.client.id = :clientId "
+ "AND a.transactionType = :transactionType AND a.raisedOn >= :since")
BigDecimal sumAmountForClientSince(@Param("clientId") Long clientId, @Param("transactionType") String transactionType,
@Param("since") LocalDateTime since);
long countByClientId(Long clientId);
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
public enum AmlAlertStatus {
RAISED, UNDER_REVIEW, WHITELISTED, ESCALATED
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
public enum AmlAppliesTo {
SAVINGS, LOAN, TRANSFER, ALL;
public boolean matches(AmlTransactionType transactionType) {
return this == ALL || this.name().equals(transactionType.name());
}
}
@@ -0,0 +1,97 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Monthly, mechanical CTR batch header - purely threshold-based (₹10L+ cash, individually or
* connected), no Principal Officer judgment involved. One row per calendar month.
*/
@Entity
@Table(name = "m_aml_ctr_report")
public class AmlCtrReport extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "report_month", nullable = false, length = 7)
private String reportMonth;
@Column(name = "generated_on", nullable = false)
private LocalDateTime generatedOn;
@Column(name = "record_count", nullable = false)
private int recordCount;
@Column(name = "file_reference", length = 150)
private String fileReference;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "submitted_on")
private LocalDateTime submittedOn;
protected AmlCtrReport() {}
public static AmlCtrReport draft(String reportMonth, LocalDateTime generatedOn, int recordCount) {
final AmlCtrReport report = new AmlCtrReport();
report.reportMonth = reportMonth;
report.generatedOn = generatedOn;
report.recordCount = recordCount;
report.status = AmlCtrReportStatus.DRAFT.name();
return report;
}
public void markGenerated(String fileReference) {
this.fileReference = fileReference;
this.status = AmlCtrReportStatus.GENERATED.name();
}
public void markSubmitted(LocalDateTime submittedOn) {
this.submittedOn = submittedOn;
this.status = AmlCtrReportStatus.SUBMITTED.name();
}
public String getReportMonth() {
return reportMonth;
}
public LocalDateTime getGeneratedOn() {
return generatedOn;
}
public int getRecordCount() {
return recordCount;
}
public String getFileReference() {
return fileReference;
}
public AmlCtrReportStatus getStatus() {
return AmlCtrReportStatus.valueOf(status);
}
public LocalDateTime getSubmittedOn() {
return submittedOn;
}
}
@@ -0,0 +1,103 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
/**
* One line per transaction (or connected-transaction group, when {@link #connectedGroup} is true)
* feeding a monthly {@link AmlCtrReport}.
*/
@Entity
@Table(name = "m_aml_ctr_report_line")
public class AmlCtrReportLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "ctr_report_id", nullable = false)
private AmlCtrReport ctrReport;
@Column(name = "transaction_type", nullable = false, length = 20)
private String transactionType;
@Column(name = "transaction_id")
private Long transactionId;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "amount", nullable = false, precision = 19, scale = 6)
private BigDecimal amount;
@Column(name = "is_connected_group", nullable = false)
private boolean connectedGroup;
protected AmlCtrReportLine() {}
public static AmlCtrReportLine of(AmlCtrReport ctrReport, AmlTransactionType transactionType, Long transactionId, Long clientId,
BigDecimal amount, boolean connectedGroup) {
final AmlCtrReportLine line = new AmlCtrReportLine();
line.ctrReport = ctrReport;
line.transactionType = transactionType.name();
line.transactionId = transactionId;
line.clientId = clientId;
line.amount = amount;
line.connectedGroup = connectedGroup;
return line;
}
public Long getId() {
return id;
}
public AmlCtrReport getCtrReport() {
return ctrReport;
}
public AmlTransactionType getTransactionType() {
return AmlTransactionType.valueOf(transactionType);
}
public Long getTransactionId() {
return transactionId;
}
public Long getClientId() {
return clientId;
}
public BigDecimal getAmount() {
return amount;
}
public boolean isConnectedGroup() {
return connectedGroup;
}
}
@@ -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.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlCtrReportLineRepository extends JpaRepository<AmlCtrReportLine, Long> {
List<AmlCtrReportLine> findByCtrReport_Id(Long ctrReportId);
}
@@ -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.aml.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlCtrReportRepository extends JpaRepository<AmlCtrReport, Long> {
Optional<AmlCtrReport> findByReportMonth(String reportMonth);
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
public enum AmlCtrReportStatus {
DRAFT, GENERATED, SUBMITTED
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
/**
* How an {@link AmlTransactionRule} is evaluated.
*
* <ul>
* <li>THRESHOLD - a single transaction at or above {@code thresholdAmount}</li>
* <li>STRUCTURING - connected transactions summing at/above {@code thresholdAmount} within {@code windowDays}</li>
* <li>VELOCITY - unusual frequency of transactions within {@code windowDays}, independent of a fixed amount</li>
* </ul>
*/
public enum AmlRuleType {
THRESHOLD, STRUCTURING, VELOCITY
}
@@ -0,0 +1,164 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Created when an {@link AmlAlert} is escalated past first-level review. {@link #concludedOn} is
* the Principal Officer's conclusion date - the RBI-mandated 7-working-day FIU filing clock
* ({@link #fiuDueDate}) starts here, never at the alert or transaction date.
*/
@Entity
@Table(name = "m_aml_str_case")
public class AmlStrCase extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@ManyToOne
@JoinColumn(name = "assigned_po")
private AppUser assignedPo;
@Column(name = "investigation_notes", length = 2000)
private String investigationNotes;
@Column(name = "source_of_funds_notes", length = 2000)
private String sourceOfFundsNotes;
@Column(name = "conclusion", length = 20)
private String conclusion;
@ManyToOne
@JoinColumn(name = "concluded_by")
private AppUser concludedBy;
@Column(name = "concluded_on")
private LocalDate concludedOn;
@Column(name = "fiu_due_date")
private LocalDate fiuDueDate;
@Column(name = "str_filed_on")
private LocalDate strFiledOn;
@Column(name = "str_reference_no", length = 100)
private String strReferenceNo;
@Column(name = "status", nullable = false, length = 25)
private String status;
protected AmlStrCase() {}
public static AmlStrCase openFor(AmlAlert alert) {
final AmlStrCase strCase = new AmlStrCase();
strCase.alert = alert;
strCase.client = alert.getClient();
strCase.status = AmlStrCaseStatus.OPEN.name();
return strCase;
}
public void assignPrincipalOfficer(AppUser po) {
this.assignedPo = po;
}
/** {@code fiuDueDate} is computed by the caller using the working-days calendar, not here. */
public void conclude(AppUser po, AmlStrConclusion conclusion, String investigationNotes, String sourceOfFundsNotes,
LocalDate concludedOn, LocalDate fiuDueDate) {
this.concludedBy = po;
this.conclusion = conclusion.name();
this.investigationNotes = investigationNotes;
this.sourceOfFundsNotes = sourceOfFundsNotes;
this.concludedOn = concludedOn;
this.status = conclusion == AmlStrConclusion.SUSPICIOUS ? AmlStrCaseStatus.CONCLUDED_SUSPICIOUS.name()
: AmlStrCaseStatus.CONCLUDED_CLEAR.name();
this.fiuDueDate = conclusion == AmlStrConclusion.SUSPICIOUS ? fiuDueDate : null;
}
public void fileStr(String referenceNo, LocalDate filedOn) {
this.strReferenceNo = referenceNo;
this.strFiledOn = filedOn;
this.status = AmlStrCaseStatus.FILED.name();
}
public Long getClientId() {
return client.getId();
}
public AmlAlert getAlert() {
return alert;
}
public Client getClient() {
return client;
}
public AppUser getAssignedPo() {
return assignedPo;
}
public String getInvestigationNotes() {
return investigationNotes;
}
public String getSourceOfFundsNotes() {
return sourceOfFundsNotes;
}
public AmlStrConclusion getConclusion() {
return conclusion == null ? null : AmlStrConclusion.valueOf(conclusion);
}
public AppUser getConcludedBy() {
return concludedBy;
}
public LocalDate getConcludedOn() {
return concludedOn;
}
public LocalDate getFiuDueDate() {
return fiuDueDate;
}
public LocalDate getStrFiledOn() {
return strFiledOn;
}
public String getStrReferenceNo() {
return strReferenceNo;
}
public AmlStrCaseStatus getStatus() {
return AmlStrCaseStatus.valueOf(status);
}
}
@@ -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.aml.domain;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
long countByClient_Id(Long clientId);
List<AmlStrCase> findByStatus(String status);
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
List<AmlStrCase> findConcludedSuspiciousBetween(@Param("from") LocalDate from, @Param("to") LocalDate to);
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.fiuDueDate < :asOf")
List<AmlStrCase> findOverdueForFiling(@Param("asOf") LocalDate asOf);
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
public enum AmlStrCaseStatus {
OPEN, CONCLUDED_CLEAR, CONCLUDED_SUSPICIOUS, FILED
}
@@ -0,0 +1,25 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
/** The Principal Officer's final conclusion on an {@link AmlStrCase}. */
public enum AmlStrConclusion {
NOT_SUSPICIOUS, SUSPICIOUS
}
@@ -0,0 +1,104 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
/**
* A machine-evaluable transaction-monitoring rule. Distinct from {@code ClientRiskRule}
* (client-profile, KYC-time scoring) - these are checked against live savings/loan/transfer
* transactions as they post. {@link #sourceClientRiskRuleId} optionally traces a rule back to its
* originating entry in the 221-rule AML catalog seeded in {@code m_client_risk_rule}.
*/
@Entity
@Table(name = "m_aml_transaction_rule")
public class AmlTransactionRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "rule_code", nullable = false, length = 50)
private String ruleCode;
@Column(name = "description", nullable = false, length = 500)
private String description;
@Column(name = "applies_to", nullable = false, length = 20)
private String appliesTo;
@Column(name = "threshold_amount", precision = 19, scale = 6)
private BigDecimal thresholdAmount;
@Column(name = "rule_type", nullable = false, length = 20)
private String ruleType;
@Column(name = "window_days")
private Integer windowDays;
@Column(name = "source_client_risk_rule_id")
private Long sourceClientRiskRuleId;
@Column(name = "is_active", nullable = false)
private boolean active;
protected AmlTransactionRule() {}
public Long getId() {
return id;
}
public String getRuleCode() {
return ruleCode;
}
public String getDescription() {
return description;
}
public AmlAppliesTo getAppliesTo() {
return AmlAppliesTo.valueOf(appliesTo);
}
public BigDecimal getThresholdAmount() {
return thresholdAmount;
}
public AmlRuleType getRuleType() {
return AmlRuleType.valueOf(ruleType);
}
public Integer getWindowDays() {
return windowDays;
}
public Long getSourceClientRiskRuleId() {
return sourceClientRiskRuleId;
}
public boolean isActive() {
return active;
}
}
@@ -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.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
}
@@ -0,0 +1,25 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
/** The kind of ledger transaction an {@link AmlAlert} was raised against. */
public enum AmlTransactionType {
SAVINGS, LOAN, TRANSFER
}
@@ -0,0 +1,138 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Immutable, append-only audit trail of a client's KYC risk-category changes. Backs the AML menu's
* existing "Risk Update History Report" item, which previously had no data behind it. Written both
* for manual re-categorization and for the automatic escalation triggered on a client's 3rd
* cumulative {@link AmlStrCase}.
*/
@Entity
@Table(name = "m_client_risk_category_history")
public class ClientRiskCategoryHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "previous_category", nullable = false)
private Integer previousCategory;
@Column(name = "new_category", nullable = false)
private Integer newCategory;
@Column(name = "reason", nullable = false, length = 500)
private String reason;
@Column(name = "triggered_by", nullable = false, length = 30)
private String triggeredBy;
@ManyToOne
@JoinColumn(name = "str_case_id")
private AmlStrCase strCase;
@Column(name = "changed_on", nullable = false)
private LocalDateTime changedOn;
@ManyToOne
@JoinColumn(name = "changed_by", nullable = false)
private AppUser changedBy;
protected ClientRiskCategoryHistory() {}
public static ClientRiskCategoryHistory autoEscalation(Client client, Integer previousCategory, Integer newCategory,
AmlStrCase triggeringCase, AppUser systemActor, LocalDateTime changedOn) {
final ClientRiskCategoryHistory history = new ClientRiskCategoryHistory();
history.client = client;
history.previousCategory = previousCategory;
history.newCategory = newCategory;
history.reason = "Auto-escalated after client's 3rd cumulative AML STR case (case #" + triggeringCase.getId() + ")";
history.triggeredBy = RiskCategoryChangeTrigger.AML_AUTO_ESCALATION.name();
history.strCase = triggeringCase;
history.changedOn = changedOn;
history.changedBy = systemActor;
return history;
}
public static ClientRiskCategoryHistory manual(Client client, Integer previousCategory, Integer newCategory, String reason,
AppUser changedBy, LocalDateTime changedOn) {
final ClientRiskCategoryHistory history = new ClientRiskCategoryHistory();
history.client = client;
history.previousCategory = previousCategory;
history.newCategory = newCategory;
history.reason = reason;
history.triggeredBy = RiskCategoryChangeTrigger.MANUAL.name();
history.changedBy = changedBy;
history.changedOn = changedOn;
return history;
}
public Long getId() {
return id;
}
public Client getClient() {
return client;
}
public Integer getPreviousCategory() {
return previousCategory;
}
public Integer getNewCategory() {
return newCategory;
}
public String getReason() {
return reason;
}
public RiskCategoryChangeTrigger getTriggeredBy() {
return RiskCategoryChangeTrigger.valueOf(triggeredBy);
}
public AmlStrCase getStrCase() {
return strCase;
}
public LocalDateTime getChangedOn() {
return changedOn;
}
public AppUser getChangedBy() {
return changedBy;
}
}
@@ -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.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClientRiskCategoryHistoryRepository extends JpaRepository<ClientRiskCategoryHistory, Long> {
List<ClientRiskCategoryHistory> findByClient_IdOrderByChangedOnDesc(Long clientId);
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.domain;
public enum RiskCategoryChangeTrigger {
AML_AUTO_ESCALATION, MANUAL
}
@@ -617,6 +617,10 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.office.getId();
}
public String getDisplayName() {
return this.displayName;
}
public String mobileNo() {
return this.mobileNo;
}
@@ -586,6 +586,15 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return riskCategory;
}
/**
* Directly sets the risk category outside the normal command-diff update path. Used by the AML
* module to auto-escalate a client's risk category after their 3rd cumulative STR case -
* every such change is recorded in {@code m_client_risk_category_history} by the caller.
*/
public void escalateRiskCategory(Integer newCategory) {
this.riskCategory = newCategory;
}
public String getPanNumber() {
return panNumber;
}
@@ -72,9 +72,24 @@ public class ClientRiskRule {
@Column(name = "risk_category", nullable = false)
private Integer riskCategory;
@Column(name = "reason", nullable = false, length = 255)
@Column(name = "reason", nullable = false, length = 500)
private String reason;
@Column(name = "aml_check_status", length = 20)
private String amlCheckStatus;
@Column(name = "banned_entity_match", length = 10)
private String bannedEntityMatch;
@Column(name = "willful_defaulter", length = 10)
private String willfulDefaulter;
@Column(name = "ecgc_sal_flag", length = 10)
private String ecgcSalFlag;
@Column(name = "dormant_flag", length = 20)
private String dormantFlag;
@Column(name = "order_position", nullable = false)
private Integer orderPosition;
@@ -135,4 +150,24 @@ public class ClientRiskRule {
public Integer getOrderPosition() {
return orderPosition;
}
public String getAmlCheckStatus() {
return amlCheckStatus;
}
public String getBannedEntityMatch() {
return bannedEntityMatch;
}
public String getWillfulDefaulter() {
return willfulDefaulter;
}
public String getEcgcSalFlag() {
return ecgcSalFlag;
}
public String getDormantFlag() {
return dormantFlag;
}
}
@@ -18,7 +18,9 @@
*/
package org.apache.fineract.portfolio.sanctions.domain;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -40,4 +42,11 @@ public interface SanctionsEntryRepository extends JpaRepository<SanctionsEntry,
void deleteAllByActiveTrue();
long countByActiveTrue();
/**
* Timestamp the active sanctions list was last loaded into the database — i.e. the data
* currency shown to callers so they know which list snapshot a screening ran against.
*/
@Query("SELECT MAX(s.createdDate) FROM SanctionsEntry s WHERE s.active = true")
Optional<OffsetDateTime> findListLastLoadedOn();
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/alerts/{id}/review}. {@code disposition}: WHITELIST | ESCALATE. */
@Getter
@Setter
@NoArgsConstructor
public class AlertReviewRequest {
private String disposition;
private String notes;
}
@@ -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.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/alerts")
@Component
@Tag(name = "AML Alerts", description = "Live transaction-monitoring alerts raised by the AML rule engine against savings, loan and fund-transfer activity.")
@RequiredArgsConstructor
public class AmlAlertApiResource {
private final PlatformSecurityContext context;
private final AmlAlertReadPlatformService readPlatformService;
private final AmlAlertWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List AML alerts",
description = "Optionally filter by status via ?status= : RAISED, UNDER_REVIEW, WHITELISTED, ESCALATED.")
public String retrieveAll(@QueryParam("status") final String status) {
this.context.authenticatedUser();
final List<AmlAlertData> alerts = this.readPlatformService.retrieve(status);
return this.toApiJsonSerializer.serialize(alerts);
}
@POST
@Path("{alertId}/review")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "AML Officer first-level review of an alert",
description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.")
public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
if ("ESCALATE".equalsIgnoreCase(request.getDisposition())) {
this.writePlatformService.escalate(alertId, request.getNotes());
} else {
this.writePlatformService.whitelist(alertId, request.getNotes());
}
final AmlAlertData updated = this.readPlatformService.retrieve(null).stream().filter(a -> a.id().equals(alertId)).findFirst()
.orElse(null);
return this.toApiJsonSerializer.serialize(updated);
}
}
@@ -0,0 +1,72 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.YearMonth;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/ctr-reports")
@Component
@Tag(name = "AML CTR Reports",
description = "Monthly, mechanical Cash Transaction Reports - threshold-based, no Principal Officer judgment involved.")
@RequiredArgsConstructor
public class AmlCtrReportApiResource {
private final PlatformSecurityContext context;
private final AmlCtrReportReadPlatformService readPlatformService;
private final AmlCtrReportGenerationService generationService;
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List generated CTR reports")
public String retrieveAll() {
this.context.authenticatedUser();
final List<AmlCtrReportData> reports = this.readPlatformService.retrieveAll();
return this.toApiJsonSerializer.serialize(reports);
}
@POST
@Path("generate")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Generate the CTR report for a month", description = "month format: yyyy-MM. Idempotent - fails if already generated.")
public String generate(final CtrGenerateRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final YearMonth month = YearMonth.parse(request.getMonth());
this.generationService.generateForMonth(month);
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveAll());
}
}
@@ -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.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlStrCaseData;
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
import org.apache.fineract.portfolio.aml.service.AmlStrCaseReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlStrCaseWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/str-cases")
@Component
@Tag(name = "AML STR Cases",
description = "Principal-Officer-only investigation and conclusion of escalated AML alerts, and STR filing to FIU-IND.")
@RequiredArgsConstructor
public class AmlStrCaseApiResource {
private final PlatformSecurityContext context;
private final AmlStrCaseReadPlatformService readPlatformService;
private final AmlStrCaseWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<AmlStrCaseData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List AML STR cases")
public String retrieveAll() {
this.context.authenticatedUser();
final List<AmlStrCaseData> cases = this.readPlatformService.retrieveAll();
return this.toApiJsonSerializer.serialize(cases);
}
@GET
@Path("{strCaseId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve one AML STR case")
public String retrieveOne(@PathParam("strCaseId") final Long strCaseId) {
this.context.authenticatedUser();
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveOne(strCaseId));
}
@GET
@Path("weekly-digest")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Internal MIS digest of STR cases concluded suspicious in the last 7 days",
description = "For compliance tracking only - does not replace each case's own 7-working-day FIU filing deadline.")
public String weeklyDigest() {
this.context.authenticatedUser();
final List<AmlStrCaseData> digest = this.readPlatformService.weeklyDigest();
return this.toApiJsonSerializer.serialize(digest);
}
@POST
@Path("{strCaseId}/assign")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Assign a Principal Officer to an STR case")
public String assign(@PathParam("strCaseId") final Long strCaseId, final StrCaseAssignRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("CONCLUDE_AML_STR");
this.writePlatformService.assignPrincipalOfficer(strCaseId, request.getPrincipalOfficerUserId());
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveOne(strCaseId));
}
@POST
@Path("{strCaseId}/conclude")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Principal Officer conclusion",
description = "conclusion NOT_SUSPICIOUS closes the case; SUSPICIOUS sets the 7-working-day FIU filing due date starting today.")
public String conclude(@PathParam("strCaseId") final Long strCaseId, final StrCaseConcludeRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("CONCLUDE_AML_STR");
final AmlStrConclusion conclusion = AmlStrConclusion.valueOf(request.getConclusion());
this.writePlatformService.conclude(strCaseId, conclusion, request.getInvestigationNotes(), request.getSourceOfFundsNotes());
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveOne(strCaseId));
}
@POST
@Path("{strCaseId}/file")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record that the STR has been filed with FIU-IND")
public String fileStr(@PathParam("strCaseId") final Long strCaseId, final StrCaseFileRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("CONCLUDE_AML_STR");
this.writePlatformService.fileStr(strCaseId, request.getReferenceNo());
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveOne(strCaseId));
}
}
@@ -0,0 +1,57 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.ClientRiskCategoryHistoryData;
import org.apache.fineract.portfolio.aml.service.ClientRiskCategoryHistoryReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clients/{clientId}/risk-category-history")
@Component
@Tag(name = "Client Risk Category History",
description = "Audit trail of a client's KYC risk-category changes, including automatic escalation after repeated AML STR cases.")
@RequiredArgsConstructor
public class ClientRiskCategoryHistoryApiResource {
private final PlatformSecurityContext context;
private final ClientRiskCategoryHistoryReadPlatformService readPlatformService;
private final DefaultToApiJsonSerializer<ClientRiskCategoryHistoryData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a client's risk-category change history")
public String retrieveForClient(@PathParam("clientId") final Long clientId) {
this.context.authenticatedUser();
final List<ClientRiskCategoryHistoryData> history = this.readPlatformService.retrieveForClient(clientId);
return this.toApiJsonSerializer.serialize(history);
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/ctr-reports/generate}. {@code month} format: yyyy-MM. */
@Getter
@Setter
@NoArgsConstructor
public class CtrGenerateRequest {
private String month;
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/str-cases/{id}/assign}. */
@Getter
@Setter
@NoArgsConstructor
public class StrCaseAssignRequest {
private Long principalOfficerUserId;
}
@@ -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.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/str-cases/{id}/conclude}. {@code conclusion}: NOT_SUSPICIOUS | SUSPICIOUS. */
@Getter
@Setter
@NoArgsConstructor
public class StrCaseConcludeRequest {
private String conclusion;
private String investigationNotes;
private String sourceOfFundsNotes;
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/str-cases/{id}/file}. */
@Getter
@Setter
@NoArgsConstructor
public class StrCaseFileRequest {
private String referenceNo;
}
@@ -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.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition) implements Serializable {
}
@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.data;
import java.io.Serializable;
import java.time.LocalDateTime;
public record AmlCtrReportData(Long id, String reportMonth, LocalDateTime generatedOn, int recordCount, String fileReference,
String status, LocalDateTime submittedOn) implements Serializable {
}
@@ -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.aml.data;
import java.io.Serializable;
import java.time.LocalDate;
public record AmlStrCaseData(Long id, Long alertId, Long clientId, String clientDisplayName, String assignedPoUsername,
String investigationNotes, String sourceOfFundsNotes, String conclusion, String concludedByUsername, LocalDate concludedOn,
LocalDate fiuDueDate, LocalDate strFiledOn, String strReferenceNo, String status, long cumulativeStrCaseCountForClient)
implements Serializable {
}
@@ -0,0 +1,92 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* Normalized view of a savings/loan/transfer transaction handed to {@code AmlRuleEvaluationService}
* by the business-event listener that observed it. Keeps the evaluation service itself decoupled
* from the savings/loan/fund-transfer domain models - each listener is responsible for building
* this context (including, where relevant, {@link #windowAggregateAmount}) from the repositories
* it already has access to.
*/
public final class AmlTransactionContext {
private final AmlTransactionType transactionType;
private final Long transactionId;
private final Client client;
private final BigDecimal amount;
private final String currencyCode;
private final LocalDateTime occurredOn;
private final boolean cash;
/**
* For STRUCTURING/VELOCITY rules: the sum of this client's qualifying transactions of this
* type within the rule's window, INCLUSIVE of this transaction. Null if not computed - such
* rules are simply skipped for this context rather than guessed at.
*/
private final BigDecimal windowAggregateAmount;
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
this.transactionType = transactionType;
this.transactionId = transactionId;
this.client = client;
this.amount = amount;
this.currencyCode = currencyCode;
this.occurredOn = occurredOn;
this.cash = cash;
this.windowAggregateAmount = windowAggregateAmount;
}
public AmlTransactionType getTransactionType() {
return transactionType;
}
public Long getTransactionId() {
return transactionId;
}
public Client getClient() {
return client;
}
public BigDecimal getAmount() {
return amount;
}
public String getCurrencyCode() {
return currencyCode;
}
public LocalDateTime getOccurredOn() {
return occurredOn;
}
public boolean isCash() {
return cash;
}
public BigDecimal getWindowAggregateAmount() {
return windowAggregateAmount;
}
}
@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.data;
import java.io.Serializable;
import java.time.LocalDateTime;
public record ClientRiskCategoryHistoryData(Long id, Long clientId, Integer previousCategory, Integer newCategory, String reason,
String triggeredBy, Long strCaseId, LocalDateTime changedOn, String changedByUsername) implements Serializable {
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.event;
/** Marker interface whose only implementation registers AML business-event listeners on startup. */
public interface AmlDomainService {
}
@@ -0,0 +1,153 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.event;
import jakarta.annotation.PostConstruct;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.event.business.BusinessEventListener;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanDisbursalTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionMakeRepaymentPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.service.AmlRuleEvaluationService;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
/**
* Registers post-business-event listeners that evaluate every live savings and loan transaction
* against the AML rule catalog, purely additively - listeners observe transactions that already
* completed successfully and never alter their outcome. Runs synchronously in the same request,
* mirroring {@code org.apache.fineract.notification.service.NotificationDomainServiceImpl}, so an
* alert is atomic with the transaction that raised it.
*/
@RequiredArgsConstructor
@Slf4j
public class AmlDomainServiceImpl implements AmlDomainService {
/**
* Fixed lookback used for every STRUCTURING/VELOCITY rule regardless of its own configured
* window_days - a v1 approximation (errs toward over-inclusion, the safer failure mode for AML
* monitoring) until per-rule windows are wired through.
*/
private static final int STRUCTURING_WINDOW_DAYS = 30;
private final BusinessEventNotifierService businessEventNotifierService;
private final AmlRuleEvaluationService ruleEvaluationService;
private final AmlSavingsTransactionQueryRepository savingsQueryRepository;
private final AmlLoanTransactionQueryRepository loanQueryRepository;
@PostConstruct
public void addListeners() {
businessEventNotifierService.addPostBusinessEventListener(SavingsDepositBusinessEvent.class, new SavingsDepositListener());
businessEventNotifierService.addPostBusinessEventListener(SavingsWithdrawalBusinessEvent.class, new SavingsWithdrawalListener());
businessEventNotifierService.addPostBusinessEventListener(LoanDisbursalTransactionBusinessEvent.class,
new LoanDisbursalListener());
businessEventNotifierService.addPostBusinessEventListener(LoanTransactionMakeRepaymentPostBusinessEvent.class,
new LoanRepaymentListener());
}
private final class SavingsDepositListener implements BusinessEventListener<SavingsDepositBusinessEvent> {
@Override
public void onBusinessEvent(SavingsDepositBusinessEvent event) {
evaluateSavingsTransaction(event.get(), true);
}
}
private final class SavingsWithdrawalListener implements BusinessEventListener<SavingsWithdrawalBusinessEvent> {
@Override
public void onBusinessEvent(SavingsWithdrawalBusinessEvent event) {
evaluateSavingsTransaction(event.get(), false);
}
}
private final class LoanDisbursalListener implements BusinessEventListener<LoanDisbursalTransactionBusinessEvent> {
@Override
public void onBusinessEvent(LoanDisbursalTransactionBusinessEvent event) {
evaluateLoanTransaction(event.get());
}
}
private final class LoanRepaymentListener implements BusinessEventListener<LoanTransactionMakeRepaymentPostBusinessEvent> {
@Override
public void onBusinessEvent(LoanTransactionMakeRepaymentPostBusinessEvent event) {
evaluateLoanTransaction(event.get());
}
}
private void evaluateSavingsTransaction(SavingsAccountTransaction transaction, boolean depositDirection) {
final Client client = transaction.getSavingsAccount().getClient();
if (client == null) {
// group/JLG savings with no individual client behind it are out of scope for this rule set
return;
}
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal())
.map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
try {
ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) {
// AML alerting must never block the underlying savings transaction it is observing
log.error("AML rule evaluation failed for savings transaction {}", transaction.getId(), e);
}
}
private void evaluateLoanTransaction(LoanTransaction transaction) {
final Client client = transaction.getLoan().getClient();
if (client == null) {
return;
}
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType)
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
try {
ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) {
log.error("AML rule evaluation failed for loan transaction {}", transaction.getId(), e);
}
}
private boolean isCash(PaymentDetail paymentDetail) {
return paymentDetail == null || Boolean.TRUE.equals(paymentDetail.getPaymentType().getIsCashPayment());
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.job;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class AmlCtrReportBatchJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private AmlCtrReportGenerationService generationService;
@Bean
public AmlCtrReportBatchTasklet amlCtrReportBatchTasklet() {
return new AmlCtrReportBatchTasklet(generationService);
}
@Bean
protected Step amlCtrReportBatchStep() {
return new StepBuilder(JobName.CTR_REPORT_GENERATION.name(), jobRepository)
.tasklet(amlCtrReportBatchTasklet(), transactionManager).build();
}
@Bean
public Job amlCtrReportBatchJob() {
return new JobBuilder(JobName.CTR_REPORT_GENERATION.name(), jobRepository).start(amlCtrReportBatchStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,55 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.job;
import java.time.YearMonth;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical
* threshold aggregation only. Admin-triggered today (see {@code AmlCtrReportApiResource}); wiring
* to a real monthly cron is a follow-up, matching the same "driven by an admin endpoint today"
* stance already used for {@code NeftBatchProcessorService}.
*/
@Slf4j
@RequiredArgsConstructor
public class AmlCtrReportBatchTasklet implements Tasklet {
private final AmlCtrReportGenerationService generationService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
final YearMonth previousMonth = YearMonth.now().minusMonths(1);
try {
final AmlCtrReport report = generationService.generateForMonth(previousMonth);
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
report.getRecordCount());
} catch (final IllegalStateException e) {
log.info("[Job:CTR_REPORT_GENERATION] {} — skipping", e.getMessage());
}
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.job;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.aml.service.AmlStrWeeklyDigestService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class AmlStrWeeklyDigestJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private AmlStrWeeklyDigestService digestService;
@Bean
public AmlStrWeeklyDigestTasklet amlStrWeeklyDigestTasklet() {
return new AmlStrWeeklyDigestTasklet(digestService);
}
@Bean
protected Step amlStrWeeklyDigestStep() {
return new StepBuilder(JobName.AML_STR_WEEKLY_DIGEST.name(), jobRepository)
.tasklet(amlStrWeeklyDigestTasklet(), transactionManager).build();
}
@Bean
public Job amlStrWeeklyDigestJob() {
return new JobBuilder(JobName.AML_STR_WEEKLY_DIGEST.name(), jobRepository).start(amlStrWeeklyDigestStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.job;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.service.AmlStrWeeklyDigestService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Periodic heartbeat that surfaces the current week's suspicious-STR count in the application log
* for ops visibility. The actual digest content (for compliance MIS use) is pulled on demand via
* {@code AmlStrCaseApiResource#weeklyDigest}, mirroring how the CTR job only drafts a report that a
* human then reviews/exports.
*/
@Slf4j
@RequiredArgsConstructor
public class AmlStrWeeklyDigestTasklet implements Tasklet {
private final AmlStrWeeklyDigestService digestService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
final List<AmlStrCase> digest = digestService.currentWeekDigest();
log.info("[Job:AML_STR_WEEKLY_DIGEST] {} STR case(s) concluded suspicious in the last 7 days", digest.size());
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,44 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.repository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Read-only aggregation over {@code FundTransfer} for AML window-based rules (structuring /
* velocity), across all channels (INTERNAL/IMPS/NEFT/RTGS).
*
* <p>
* Lives under {@code .repository} (not {@code .service}) because {@code JPAConfig} only scans
* {@code org.apache.fineract.**.domain} and {@code org.apache.fineract.**.repository} for
* {@code @EnableJpaRepositories}.
*/
public interface AmlFundTransferQueryRepository extends JpaRepository<FundTransfer, Long> {
@Query("SELECT COALESCE(SUM(t.amount), 0) FROM FundTransfer t WHERE t.clientId = :clientId "
+ "AND t.status = :status AND t.initiatedAt >= :since")
BigDecimal sumForClientSince(@Param("clientId") Long clientId, @Param("status") FundTransferStatus status,
@Param("since") LocalDateTime since);
}
@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.repository;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Read-only aggregation queries over {@code LoanTransaction} for AML window-based rules
* (structuring / velocity). Direction/kind (repayment-like) is filtered in Java via
* {@link LoanTransaction#isRepaymentLikeType()} rather than in JPQL.
*
* <p>
* Lives under {@code .repository} (not {@code .service}) because {@code JPAConfig} only scans
* {@code org.apache.fineract.**.domain} and {@code org.apache.fineract.**.repository} for
* {@code @EnableJpaRepositories}.
*/
public interface AmlLoanTransactionQueryRepository extends JpaRepository<LoanTransaction, Long> {
@Query("SELECT t FROM LoanTransaction t WHERE t.loan.client.id = :clientId AND t.dateOf >= :since AND t.reversed = false")
List<LoanTransaction> findForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDate since);
@Query("SELECT t FROM LoanTransaction t WHERE t.dateOf BETWEEN :start AND :end AND t.reversed = false")
List<LoanTransaction> findInDateRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.repository;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Read-only aggregation queries over {@code SavingsAccountTransaction} for AML window-based rules
* (structuring / velocity). Kept separate from {@code SavingsAccountTransactionRepository} so the
* AML module doesn't modify savings module code - direction (deposit/withdrawal) is filtered in
* Java via {@link SavingsAccountTransaction#isDeposit()}/{@code isWithdrawal()} rather than in
* JPQL, since {@code typeOf} there is a raw internal code.
*
* <p>
* Lives under {@code .repository} (not {@code .service}) because {@code JPAConfig} only scans
* {@code org.apache.fineract.**.domain} and {@code org.apache.fineract.**.repository} for
* {@code @EnableJpaRepositories}.
*/
public interface AmlSavingsTransactionQueryRepository extends JpaRepository<SavingsAccountTransaction, Long> {
@Query("SELECT t FROM SavingsAccountTransaction t WHERE t.savingsAccount.client.id = :clientId "
+ "AND t.dateOf >= :since AND t.reversed = false")
List<SavingsAccountTransaction> findForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDate since);
@Query("SELECT t FROM SavingsAccountTransaction t WHERE t.dateOf BETWEEN :start AND :end AND t.reversed = false")
List<SavingsAccountTransaction> findInDateRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
}
@@ -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.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
public interface AmlAlertReadPlatformService {
/** @param statusFilter one of RAISED/UNDER_REVIEW/WHITELISTED/ESCALATED, or null/blank for all. */
List<AmlAlertData> retrieve(String statusFilter);
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
private final AmlAlertRepository repository;
@Override
public List<AmlAlertData> retrieve(String statusFilter) {
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData)
.collect(Collectors.toList());
}
private AmlAlertData mapToData(AmlAlert alert) {
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name());
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
/** First-level AML Officer disposition on an {@link AmlAlert}. */
public interface AmlAlertWritePlatformService {
AmlAlert whitelist(Long alertId, String notes);
AmlStrCase escalate(Long alertId, String notes);
}
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService {
private final AmlAlertRepository alertRepository;
private final AmlStrCaseRepository strCaseRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
@Override
public AmlAlert whitelist(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
alert.whitelist(context.authenticatedUser(), notes);
return alertRepository.save(alert);
}
@Override
public AmlStrCase escalate(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
alert.escalate(context.authenticatedUser(), notes);
alertRepository.save(alert);
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
return strCase;
}
private AmlAlert loadAlert(Long alertId) {
return alertRepository.findById(alertId)
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
}
}
@@ -0,0 +1,140 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Monthly, mechanical CTR batch: every individually-large cash transaction (savings or loan, ≥
* ₹10L) gets its own line; every client whose smaller cash transactions of a single type sum to ≥
* ₹10L within the month gets one connected-group line for that type. Purely threshold-based - no
* Principal Officer judgment, matching the regulation. NEFT/RTGS transfers are non-cash by nature
* and are out of scope for CTR.
*
* <p>
* Known v1 limitation: a client splitting cash activity across BOTH savings and loan channels to
* stay under ₹10L in each is not caught by this aggregation - each channel is summed separately.
*/
@Service
@RequiredArgsConstructor
@Transactional
public class AmlCtrReportGenerationService {
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
private final AmlCtrReportRepository ctrReportRepository;
private final AmlCtrReportLineRepository ctrReportLineRepository;
private final AmlSavingsTransactionQueryRepository savingsQueryRepository;
private final AmlLoanTransactionQueryRepository loanQueryRepository;
public AmlCtrReport generateForMonth(YearMonth month) {
final String reportMonth = month.toString();
if (ctrReportRepository.findByReportMonth(reportMonth).isPresent()) {
throw new IllegalStateException("CTR report for " + reportMonth + " has already been generated");
}
final LocalDate start = month.atDay(1);
final LocalDate end = month.atEndOfMonth();
final List<PendingLine> pendingLines = new ArrayList<>();
final Map<Long, BigDecimal> smallSavingsCashByClient = new HashMap<>();
final Map<Long, BigDecimal> smallLoanCashByClient = new HashMap<>();
for (final SavingsAccountTransaction t : savingsQueryRepository.findInDateRange(start, end)) {
if (!(t.isDeposit() || t.isWithdrawal()) || !isCash(t.getPaymentDetail())) {
continue;
}
final Client client = t.getSavingsAccount().getClient();
if (client == null) {
continue;
}
if (t.getAmount().compareTo(CTR_THRESHOLD) >= 0) {
pendingLines.add(new PendingLine(AmlTransactionType.SAVINGS, t.getId(), client.getId(), t.getAmount(), false));
} else {
smallSavingsCashByClient.merge(client.getId(), t.getAmount(), BigDecimal::add);
}
}
for (final LoanTransaction t : loanQueryRepository.findInDateRange(start, end)) {
if (!t.isRepaymentLikeType() || !isCash(t.getPaymentDetail())) {
continue;
}
final Client client = t.getLoan().getClient();
if (client == null) {
continue;
}
final BigDecimal amount = t.getAmount(t.getLoan().getCurrency()).getAmount();
if (amount.compareTo(CTR_THRESHOLD) >= 0) {
pendingLines.add(new PendingLine(AmlTransactionType.LOAN, t.getId(), client.getId(), amount, false));
} else {
smallLoanCashByClient.merge(client.getId(), amount, BigDecimal::add);
}
}
addConnectedGroupLines(pendingLines, smallSavingsCashByClient, AmlTransactionType.SAVINGS);
addConnectedGroupLines(pendingLines, smallLoanCashByClient, AmlTransactionType.LOAN);
final AmlCtrReport report = ctrReportRepository
.save(AmlCtrReport.draft(reportMonth, LocalDateTime.now(), pendingLines.size()));
for (final PendingLine pending : pendingLines) {
ctrReportLineRepository.save(AmlCtrReportLine.of(report, pending.transactionType(), pending.transactionId(),
pending.clientId(), pending.amount(), pending.connectedGroup()));
}
return report;
}
private void addConnectedGroupLines(List<PendingLine> pendingLines, Map<Long, BigDecimal> smallAmountsByClient,
AmlTransactionType transactionType) {
smallAmountsByClient.forEach((clientId, total) -> {
if (total.compareTo(CTR_THRESHOLD) >= 0) {
pendingLines.add(new PendingLine(transactionType, null, clientId, total, true));
}
});
}
/** Holds a line's values before the parent {@link AmlCtrReport} row exists to attach them to. */
private record PendingLine(AmlTransactionType transactionType, Long transactionId, Long clientId, BigDecimal amount,
boolean connectedGroup) {
}
private boolean isCash(PaymentDetail paymentDetail) {
return paymentDetail == null || Boolean.TRUE.equals(paymentDetail.getPaymentType().getIsCashPayment());
}
}
@@ -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.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
public interface AmlCtrReportReadPlatformService {
List<AmlCtrReportData> retrieveAll();
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
private final AmlCtrReportRepository repository;
@Override
public List<AmlCtrReportData> retrieveAll() {
return repository.findAll().stream().sorted(Comparator.comparing(AmlCtrReport::getReportMonth).reversed())
.map(this::mapToData).collect(Collectors.toList());
}
private AmlCtrReportData mapToData(AmlCtrReport report) {
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
}
}
@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlFundTransferQueryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Entry point for AML monitoring of NEFT/RTGS/IMPS/INTERNAL fund transfers. This module's
* fund-transfer code (unlike savings/loans) doesn't use Fineract's BusinessEvent framework - it
* has its own simpler {@code FundTransferEvent} append-only status log - so rather than bolting on
* an unrelated event bus, this is called directly from the write paths (synchronous strategy
* execution and the NEFT batch processor) at the point a transfer reaches SUCCESS.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class AmlFundTransferEvaluationService {
private static final int STRUCTURING_WINDOW_DAYS = 30;
private final AmlRuleEvaluationService ruleEvaluationService;
private final AmlFundTransferQueryRepository fundTransferQueryRepository;
private final ClientRepositoryWrapper clientRepositoryWrapper;
public void evaluateIfSuccessful(FundTransfer transfer) {
if (transfer.getStatus() != FundTransferStatus.SUCCESS || transfer.getClientId() == null) {
return;
}
try {
final Client client = clientRepositoryWrapper.findOneWithNotFoundDetection(transfer.getClientId());
final LocalDateTime since = LocalDateTime.now().minusDays(STRUCTURING_WINDOW_DAYS);
final BigDecimal windowAggregate = fundTransferQueryRepository.sumForClientSince(client.getId(), FundTransferStatus.SUCCESS,
since);
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.TRANSFER, transfer.getId(), client,
transfer.getAmount(), "INR",
transfer.getSettledAt() != null ? transfer.getSettledAt() : transfer.getInitiatedAt(), false, windowAggregate);
ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) {
// AML alerting must never block the underlying fund transfer it is observing
log.error("AML rule evaluation failed for fund transfer {}", transfer.getId(), e);
}
}
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
/**
* Evaluates a single transaction against the active {@code AmlTransactionRule} catalog and
* persists one {@link AmlAlert} per rule hit. Runs identically regardless of the client's current
* KYC risk category - risk category is never used to suppress evaluation.
*/
public interface AmlRuleEvaluationService {
List<AmlAlert> evaluate(AmlTransactionContext context);
}
@@ -0,0 +1,75 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository;
@Override
public List<AmlAlert> evaluate(AmlTransactionContext context) {
final List<AmlTransactionRule> candidates = new ArrayList<>();
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name()));
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name()));
final List<AmlAlert> raised = new ArrayList<>();
for (final AmlTransactionRule rule : candidates) {
if (isHit(rule, context)) {
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), rule,
context.getAmount(), context.getCurrencyCode(), context.getOccurredOn());
raised.add(alertRepository.save(alert));
}
}
return raised;
}
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
if (rule.getThresholdAmount() == null) {
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
// per rule and are not yet evaluated here - they exist in the catalog for future work.
return false;
}
switch (rule.getRuleType()) {
case THRESHOLD:
return context.getAmount().compareTo(rule.getThresholdAmount()) >= 0;
case STRUCTURING:
case VELOCITY:
return context.getWindowAggregateAmount() != null
&& context.getWindowAggregateAmount().compareTo(rule.getThresholdAmount()) >= 0;
default:
return false;
}
}
}
@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlStrCaseData;
public interface AmlStrCaseReadPlatformService {
List<AmlStrCaseData> retrieveAll();
AmlStrCaseData retrieveOne(Long strCaseId);
List<AmlStrCaseData> weeklyDigest();
}
@@ -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.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlStrCaseData;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlStrCaseReadPlatformServiceImpl implements AmlStrCaseReadPlatformService {
private final AmlStrCaseRepository repository;
private final AmlStrWeeklyDigestService digestService;
@Override
public List<AmlStrCaseData> retrieveAll() {
return repository.findAll().stream().map(this::mapToData).collect(Collectors.toList());
}
@Override
public AmlStrCaseData retrieveOne(Long strCaseId) {
return repository.findById(strCaseId).map(this::mapToData)
.orElseThrow(() -> new EntityNotFoundException("AML STR case " + strCaseId + " does not exist"));
}
@Override
public List<AmlStrCaseData> weeklyDigest() {
return digestService.currentWeekDigest().stream().map(this::mapToData).collect(Collectors.toList());
}
private AmlStrCaseData mapToData(AmlStrCase strCase) {
final long cumulative = repository.countByClient_Id(strCase.getClient().getId());
return new AmlStrCaseData(strCase.getId(), strCase.getAlert().getId(), strCase.getClient().getId(),
strCase.getClient().getDisplayName(), strCase.getAssignedPo() == null ? null : strCase.getAssignedPo().getUsername(),
strCase.getInvestigationNotes(), strCase.getSourceOfFundsNotes(),
strCase.getConclusion() == null ? null : strCase.getConclusion().name(),
strCase.getConcludedBy() == null ? null : strCase.getConcludedBy().getUsername(), strCase.getConcludedOn(),
strCase.getFiuDueDate(), strCase.getStrFiledOn(), strCase.getStrReferenceNo(), strCase.getStatus().name(), cumulative);
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
/** Principal-Officer-only actions on an escalated {@link AmlStrCase}. */
public interface AmlStrCaseWritePlatformService {
AmlStrCase assignPrincipalOfficer(Long strCaseId, Long principalOfficerUserId);
AmlStrCase conclude(Long strCaseId, AmlStrConclusion conclusion, String investigationNotes, String sourceOfFundsNotes);
AmlStrCase fileStr(Long strCaseId, String referenceNo);
}
@@ -0,0 +1,75 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
private final AmlStrCaseRepository strCaseRepository;
private final AppUserRepository appUserRepository;
private final PlatformSecurityContext context;
private final StrSlaCalculator strSlaCalculator;
@Override
public AmlStrCase assignPrincipalOfficer(Long strCaseId, Long principalOfficerUserId) {
final AmlStrCase strCase = loadCase(strCaseId);
final AppUser po = appUserRepository.findById(principalOfficerUserId)
.orElseThrow(() -> new EntityNotFoundException("User " + principalOfficerUserId + " does not exist"));
strCase.assignPrincipalOfficer(po);
return strCaseRepository.save(strCase);
}
@Override
public AmlStrCase conclude(Long strCaseId, AmlStrConclusion conclusion, String investigationNotes, String sourceOfFundsNotes) {
final AmlStrCase strCase = loadCase(strCaseId);
final AppUser principalOfficer = context.authenticatedUser();
final LocalDate concludedOn = LocalDate.now();
final LocalDate fiuDueDate = conclusion == AmlStrConclusion.SUSPICIOUS
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
: null;
strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate);
return strCaseRepository.save(strCase);
}
@Override
public AmlStrCase fileStr(Long strCaseId, String referenceNo) {
final AmlStrCase strCase = loadCase(strCaseId);
strCase.fileStr(referenceNo, LocalDate.now());
return strCaseRepository.save(strCase);
}
private AmlStrCase loadCase(Long strCaseId) {
return strCaseRepository.findById(strCaseId)
.orElseThrow(() -> new EntityNotFoundException("AML STR case " + strCaseId + " does not exist"));
}
}
@@ -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.aml.service;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Internal MIS view of STR cases the Principal Officer concluded suspicious in the last 7 calendar
* days. This is separate from, and does not replace, each case's own individual 7-working-day FIU
* filing deadline ({@code AmlStrCase.fiuDueDate}) - it exists purely for compliance-team tracking.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlStrWeeklyDigestService {
private static final int DIGEST_WINDOW_DAYS = 7;
private final AmlStrCaseRepository strCaseRepository;
public List<AmlStrCase> currentWeekDigest() {
final LocalDate today = LocalDate.now();
return strCaseRepository.findConcludedSuspiciousBetween(today.minusDays(DIGEST_WINDOW_DAYS), today);
}
public List<AmlStrCase> digestFor(LocalDate from, LocalDate to) {
return strCaseRepository.findConcludedSuspiciousBetween(from, to);
}
}
@@ -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.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.ClientRiskCategoryHistoryData;
public interface ClientRiskCategoryHistoryReadPlatformService {
List<ClientRiskCategoryHistoryData> retrieveForClient(Long clientId);
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.ClientRiskCategoryHistoryData;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ClientRiskCategoryHistoryReadPlatformServiceImpl implements ClientRiskCategoryHistoryReadPlatformService {
private final ClientRiskCategoryHistoryRepository repository;
@Override
public List<ClientRiskCategoryHistoryData> retrieveForClient(Long clientId) {
return repository.findByClient_IdOrderByChangedOnDesc(clientId).stream().map(this::mapToData).collect(Collectors.toList());
}
private ClientRiskCategoryHistoryData mapToData(ClientRiskCategoryHistory history) {
return new ClientRiskCategoryHistoryData(history.getId(), history.getClient().getId(), history.getPreviousCategory(),
history.getNewCategory(), history.getReason(), history.getTriggeredBy().name(),
history.getStrCase() == null ? null : history.getStrCase().getId(), history.getChangedOn(),
history.getChangedBy().getUsername());
}
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
/**
* A client's existing static KYC risk category never suppresses AML monitoring - a Low/Medium
* risk client who breaches a rule still gets an alert and still goes through the STR pipeline.
* This service closes the feedback loop the other way: on a client's 3rd cumulative
* {@link AmlStrCase}, their risk category is automatically escalated one level, so repeated
* breaches are reflected back into their KYC profile rather than only living in the AML module.
*/
public interface ClientRiskEscalationService {
void onStrCaseEscalated(AmlStrCase strCase);
}
@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.KycRiskCategory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ClientRiskEscalationServiceImpl implements ClientRiskEscalationService {
private static final int ESCALATION_THRESHOLD = 3;
private final AmlStrCaseRepository strCaseRepository;
private final ClientKycRepositoryWrapper clientKycRepositoryWrapper;
private final ClientRiskCategoryHistoryRepository historyRepository;
private final PlatformSecurityContext context;
@Override
public void onStrCaseEscalated(AmlStrCase strCase) {
final Client client = strCase.getClient();
final long cumulativeCases = strCaseRepository.countByClient_Id(client.getId());
if (cumulativeCases != ESCALATION_THRESHOLD) {
// only fires on the exact 3rd case, not every case from the 3rd onward
return;
}
final ClientKyc kyc = clientKycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
final Integer previousCategory = kyc.getRiskCategoryValue();
final Integer nextCategory = nextCategory(previousCategory);
if (nextCategory.equals(previousCategory)) {
log.info("Client {} reached {} cumulative AML STR cases but is already at the highest risk category", client.getId(),
cumulativeCases);
return;
}
kyc.escalateRiskCategory(nextCategory);
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
context.authenticatedUser(), LocalDateTime.now()));
}
private Integer nextCategory(Integer currentCategory) {
if (currentCategory == null || currentCategory.equals(KycRiskCategory.LOW.getValue())) {
return KycRiskCategory.MEDIUM.getValue();
}
if (currentCategory.equals(KycRiskCategory.MEDIUM.getValue())) {
return KycRiskCategory.HIGH.getValue();
}
return KycRiskCategory.HIGH.getValue();
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.service;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.holiday.domain.HolidayRepositoryWrapper;
import org.apache.fineract.organisation.workingdays.domain.WorkingDays;
import org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepositoryWrapper;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysUtil;
import org.springframework.stereotype.Service;
/**
* Computes the RBI/PMLA-mandated 7-working-day FIU-IND filing deadline from a Principal Officer's
* STR conclusion date. "Working day" excludes both the tenant's configured weekly off-days
* ({@link WorkingDays}) and declared office holidays - reuses the same calendar machinery loan
* repayment scheduling already depends on, rather than a bespoke calculator.
*/
@Service
@RequiredArgsConstructor
public class StrSlaCalculator {
private static final int STR_FILING_WORKING_DAYS = 7;
private final WorkingDaysRepositoryWrapper workingDaysRepositoryWrapper;
private final HolidayRepositoryWrapper holidayRepositoryWrapper;
public LocalDate calculateFiuDueDate(LocalDate concludedOn, Long officeId) {
final WorkingDays workingDays = workingDaysRepositoryWrapper.findOne();
LocalDate date = concludedOn;
int workingDaysElapsed = 0;
while (workingDaysElapsed < STR_FILING_WORKING_DAYS) {
date = date.plusDays(1);
if (isWorkingDay(workingDays, date, officeId)) {
workingDaysElapsed++;
}
}
return date;
}
private boolean isWorkingDay(WorkingDays workingDays, LocalDate date, Long officeId) {
return WorkingDaysUtil.isWorkingDay(workingDays, date) && !holidayRepositoryWrapper.isHoliday(officeId, date);
}
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.aml.starter;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.aml.event.AmlDomainService;
import org.apache.fineract.portfolio.aml.event.AmlDomainServiceImpl;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.service.AmlRuleEvaluationService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AmlConfiguration {
@Bean
@ConditionalOnMissingBean(AmlDomainService.class)
public AmlDomainService amlDomainService(BusinessEventNotifierService businessEventNotifierService,
AmlRuleEvaluationService amlRuleEvaluationService, AmlSavingsTransactionQueryRepository savingsQueryRepository,
AmlLoanTransactionQueryRepository loanQueryRepository) {
return new AmlDomainServiceImpl(businessEventNotifierService, amlRuleEvaluationService, savingsQueryRepository,
loanQueryRepository);
}
}
@@ -44,6 +44,7 @@ import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.data.AuditData;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.AuditReadPlatformService;
@@ -142,7 +143,8 @@ public class ClientsApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Clients", description = "The list capability of clients can support pagination and sorting.\n\n"
+ "Example Requests:\n" + "\n" + "clients\n" + "\n" + "clients?fields=displayName,officeName,timeline\n" + "\n"
+ "clients?offset=10&limit=50\n" + "\n" + "clients?orderBy=displayName&sortOrder=DESC")
+ "clients?offset=10&limit=50\n" + "\n" + "clients?orderBy=displayName&sortOrder=DESC\n\n"
+ "clients?fromDate=2026-01-01&toDate=2026-01-31 (submittedon_date range, yyyy-MM-dd, inclusive)")
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsResponse.class)))
public String retrieveAll(@Context final UriInfo uriInfo,
@QueryParam("officeId") @Parameter(description = "officeId") final Long officeId,
@@ -157,10 +159,15 @@ public class ClientsApiResource {
@QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy,
@QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder,
@QueryParam("orphansOnly") @Parameter(description = "orphansOnly") final Boolean orphansOnly,
@QueryParam("legalForm") final Integer legalForm) {
@QueryParam("legalForm") final Integer legalForm,
@QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate,
@QueryParam("toDate") @Parameter(description = "toDate") final String toDate) {
final LocalDate fromDateParsed = StringUtils.isNotBlank(fromDate) ? LocalDate.parse(fromDate) : null;
final LocalDate toDateParsed = StringUtils.isNotBlank(toDate) ? LocalDate.parse(toDate) : null;
return retrieveAll(uriInfo, officeId, externalId, displayName, firstname, lastname, status, legalForm, hierarchy, offset, limit,
orderBy, sortOrder, orphansOnly, false);
orderBy, sortOrder, orphansOnly, false, fromDateParsed, toDateParsed);
}
@GET
@@ -437,7 +444,7 @@ public class ClientsApiResource {
public String retrieveAll(final UriInfo uriInfo, final Long officeId, final String externalId, final String displayName,
final String firstname, final String lastname, final String status, final Integer legalForm, final String hierarchy,
final Integer offset, final Integer limit, final String orderBy, final String sortOrder, final Boolean orphansOnly,
final boolean isSelfUser) {
final boolean isSelfUser, final LocalDate fromDate, final LocalDate toDate) {
context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
sqlValidator.validate(orderBy);
sqlValidator.validate(sortOrder);
@@ -445,7 +452,8 @@ public class ClientsApiResource {
sqlValidator.validate(hierarchy);
final SearchParameters searchParameters = SearchParameters.builder().limit(limit).officeId(officeId).externalId(externalId)
.name(displayName).hierarchy(hierarchy).firstname(firstname).lastname(lastname).status(status).orphansOnly(orphansOnly)
.isSelfUser(isSelfUser).offset(offset).orderBy(orderBy).sortOrder(sortOrder).legalForm(legalForm).build();
.isSelfUser(isSelfUser).offset(offset).orderBy(orderBy).sortOrder(sortOrder).legalForm(legalForm).fromDate(fromDate)
.toDate(toDate).build();
final Page<ClientData> clientData = clientReadPlatformService.retrieveAll(searchParameters);
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return toApiJsonSerializer.serialize(settings, clientData, ClientApiConstants.CLIENT_RESPONSE_DATA_PARAMETERS);
@@ -41,6 +41,11 @@ public final class KycRiskRuleData implements Serializable {
private final Integer riskCategory;
private final String reason;
private final Integer orderPosition;
private final String amlCheckStatus;
private final String bannedEntityMatch;
private final String willfulDefaulter;
private final String ecgcSalFlag;
private final String dormantFlag;
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName,
@@ -48,7 +53,9 @@ public final class KycRiskRuleData implements Serializable {
final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome,
final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
final Integer riskCategory, final String reason, final Integer orderPosition) {
final Integer riskCategory, final String reason, final Integer orderPosition,
final String amlCheckStatus, final String bannedEntityMatch,
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag) {
this.id = id;
this.ruleName = ruleName;
this.legalFormId = legalFormId;
@@ -65,6 +72,11 @@ public final class KycRiskRuleData implements Serializable {
this.riskCategory = riskCategory;
this.reason = reason;
this.orderPosition = orderPosition;
this.amlCheckStatus = amlCheckStatus;
this.bannedEntityMatch = bannedEntityMatch;
this.willfulDefaulter = willfulDefaulter;
this.ecgcSalFlag = ecgcSalFlag;
this.dormantFlag = dormantFlag;
}
public Long getId() {
@@ -130,4 +142,24 @@ public final class KycRiskRuleData implements Serializable {
public Integer getOrderPosition() {
return orderPosition;
}
public String getAmlCheckStatus() {
return amlCheckStatus;
}
public String getBannedEntityMatch() {
return bannedEntityMatch;
}
public String getWillfulDefaulter() {
return willfulDefaulter;
}
public String getEcgcSalFlag() {
return ecgcSalFlag;
}
public String getDormantFlag() {
return dormantFlag;
}
}
@@ -178,6 +178,16 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
extraCriteria += " and c.status_enum = " + clientStatus.getValue().toString() + " ";
}
if (searchParameters.hasFromDate()) {
paramList.add(searchParameters.getFromDate().toString());
extraCriteria += " and c.submittedon_date >= ?::date ";
}
if (searchParameters.hasToDate()) {
paramList.add(searchParameters.getToDate().toString());
extraCriteria += " and c.submittedon_date <= ?::date ";
}
if (firstname != null) {
paramList.add(firstname);
extraCriteria += " and c.firstname like ? ";
@@ -62,7 +62,12 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
rule.getMaxAnnualTurnover(),
rule.getRiskCategory(),
rule.getReason(),
rule.getOrderPosition()
rule.getOrderPosition(),
rule.getAmlCheckStatus(),
rule.getBannedEntityMatch(),
rule.getWillfulDefaulter(),
rule.getEcgcSalFlag(),
rule.getDormantFlag()
);
}
}
@@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
import org.apache.fineract.infrastructure.imps.data.PartyData;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.service.AmlFundTransferEvaluationService;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.fundtransfer.data.ResolvedLimits;
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
@@ -76,6 +77,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
private final SanctionsScreeningService sanctionsScreeningService;
private final TransferChannelWindowValidator windowValidator;
private final SuspenseGlService suspenseGl;
private final AmlFundTransferEvaluationService amlFundTransferEvaluationService;
// ─── /verify ──────────────────────────────────────────────────────────
@@ -168,6 +170,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
eventRepository.save(FundTransferEvent.of(saved.getId(), null, saved.getStatus(),
needsApproval ? "INITIATED_PENDING_APPROVAL" : "INITIATED_AUTO_EXECUTE", null, cashier.getId()));
amlFundTransferEvaluationService.evaluateIfSuccessful(saved);
return FundTransferReadPlatformServiceImpl.toData(saved);
}
@@ -190,6 +193,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
executeStrategy(t);
FundTransfer saved = fundTransferRepository.save(t);
eventRepository.save(FundTransferEvent.of(saved.getId(), prev, saved.getStatus(), "APPROVED", null, approver.getId()));
amlFundTransferEvaluationService.evaluateIfSuccessful(saved);
return FundTransferReadPlatformServiceImpl.toData(saved);
}
@@ -26,6 +26,7 @@ import org.apache.fineract.infrastructure.imps.data.AccountData;
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
import org.apache.fineract.infrastructure.imps.data.PartyData;
import org.apache.fineract.portfolio.aml.service.AmlFundTransferEvaluationService;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEvent;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEventRepository;
@@ -54,6 +55,7 @@ public class NeftBatchProcessorService {
private final FundTransferEventRepository eventRepository;
private final ImpsSwitchClient switchClient;
private final SuspenseGlService suspenseGl;
private final AmlFundTransferEvaluationService amlFundTransferEvaluationService;
@Value("${fineract.imps.our-default-ifsc:BANK0000001}")
private String ourDefaultIfsc;
@@ -109,6 +111,7 @@ public class NeftBatchProcessorService {
fundTransferRepository.save(t);
eventRepository.save(FundTransferEvent.of(t.getId(), prev, mapped, "NEFT_BATCH_PROCESSED",
"code=" + code, null));
amlFundTransferEvaluationService.evaluateIfSuccessful(t);
log.info("[NeftBatchProcessor] txnRef={} {} → {} utr={}", t.getTxnRef(), prev, mapped, t.getUtr());
return mapped;
} catch (RuntimeException ex) {
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.sanctions.data;
import java.time.OffsetDateTime;
import java.util.List;
/**
@@ -32,10 +33,11 @@ public final class SanctionsScreeningResult {
private final String message;
private final int matchCount;
private final List<SanctionsMatchData> matches;
private final OffsetDateTime listDataAsOf;
private SanctionsScreeningResult(final Long screeningLogId, final String screenedName, final boolean matchFound,
final String status, final String message, final int matchCount,
final List<SanctionsMatchData> matches) {
final List<SanctionsMatchData> matches, final OffsetDateTime listDataAsOf) {
this.screeningLogId = screeningLogId;
this.screenedName = screenedName;
this.matchFound = matchFound;
@@ -43,20 +45,22 @@ public final class SanctionsScreeningResult {
this.message = message;
this.matchCount = matchCount;
this.matches = matches;
this.listDataAsOf = listDataAsOf;
}
public static SanctionsScreeningResult matched(final Long screeningLogId, final String screenedName,
final List<SanctionsMatchData> matches) {
final List<SanctionsMatchData> matches, final OffsetDateTime listDataAsOf) {
return new SanctionsScreeningResult(screeningLogId, screenedName, true, "FLAGGED",
"Potential match found in UN Security Council consolidated sanctions list. "
+ "Manual review required before proceeding.",
matches.size(), matches);
matches.size(), matches, listDataAsOf);
}
public static SanctionsScreeningResult clear(final Long screeningLogId, final String screenedName) {
public static SanctionsScreeningResult clear(final Long screeningLogId, final String screenedName,
final OffsetDateTime listDataAsOf) {
return new SanctionsScreeningResult(screeningLogId, screenedName, false, "CLEAR",
"No match found in UN Security Council consolidated sanctions list.",
0, List.of());
0, List.of(), listDataAsOf);
}
public Long getScreeningLogId() { return screeningLogId; }
@@ -66,4 +70,5 @@ public final class SanctionsScreeningResult {
public String getMessage() { return message; }
public int getMatchCount() { return matchCount; }
public List<SanctionsMatchData> getMatches() { return matches; }
public OffsetDateTime getListDataAsOf() { return listDataAsOf; }
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.sanctions.service;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@@ -54,18 +55,19 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
final Long clientId, final String ipAddress) {
if (name == null || name.isBlank()) {
final Long logId = saveLog(name, "CLEAR", 0, null, null, source, clientId, ipAddress);
return SanctionsScreeningResult.clear(logId, name);
return SanctionsScreeningResult.clear(logId, name, sanctionsEntryRepository.findListLastLoadedOn().orElse(null));
}
final String trimmedName = name.trim();
LOG.info("Screening name against sanctions list: {}", trimmedName);
final OffsetDateTime listDataAsOf = sanctionsEntryRepository.findListLastLoadedOn().orElse(null);
final List<SanctionsEntry> matches = sanctionsEntryRepository.searchByNameOrAlias(trimmedName);
if (matches.isEmpty()) {
LOG.info("No sanctions match found for: {}", trimmedName);
final Long logId = saveLog(trimmedName, "CLEAR", 0, null, null, source, clientId, ipAddress);
return SanctionsScreeningResult.clear(logId, trimmedName);
return SanctionsScreeningResult.clear(logId, trimmedName, listDataAsOf);
}
LOG.warn("Sanctions match found for '{}': {} potential match(es)", trimmedName, matches.size());
@@ -84,7 +86,7 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
final Long logId = saveLog(trimmedName, "FLAGGED", matches.size(), matchedEntryIds, matchedNames, source, clientId, ipAddress);
return SanctionsScreeningResult.matched(logId, trimmedName, matchDataList);
return SanctionsScreeningResult.matched(logId, trimmedName, matchDataList, listDataAsOf);
}
@Override
@@ -135,7 +135,7 @@ public class SelfClientsApiResource {
final String hierarchy = null;
final Boolean orphansOnly = null;
return this.clientApiResource.retrieveAll(uriInfo, officeId, externalId, displayName, firstname, lastname, status, legalForm,
hierarchy, offset, limit, orderBy, sortOrder, orphansOnly, true);
hierarchy, offset, limit, orderBy, sortOrder, orphansOnly, true, null, null);
}
@GET
@@ -0,0 +1,46 @@
package org.apache.fineract.portfolio.shareaccounts.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountClientSummaryData;
import org.apache.fineract.portfolio.shareaccounts.service.ShareAccountReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/shareaccounts/summary")
@Component
@Tag(name = "Share Account Summary", description = "Provides a highly optimized summary of all share accounts linked with their respective clients.")
@RequiredArgsConstructor
public class ShareAccountSummaryApiResource {
private final PlatformSecurityContext platformSecurityContext;
private final ShareAccountReadPlatformService shareAccountReadPlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List all share accounts with client details", description = "Retrieves all share accounts along with their associated client details in a single optimized query.\n\n"
+ "Example Requests:\n\n" + "shareaccounts/summary")
@ApiResponse(responseCode = "200", description = "OK")
public Collection<ShareAccountClientSummaryData> retrieveAllSummaries(@QueryParam("searchTerm") final String searchTerm) {
long start = System.currentTimeMillis();
System.out.println("[TIMING] Start of retrieveAllSummaries (before auth)");
this.platformSecurityContext.authenticatedUser();
long afterAuth = System.currentTimeMillis();
System.out.println("[TIMING] Auth check: " + (afterAuth - start) + "ms");
Collection<ShareAccountClientSummaryData> result = this.shareAccountReadPlatformService.retrieveAllShareAccountClientSummaries(searchTerm);
long afterService = System.currentTimeMillis();
System.out.println("[TIMING] Service call total (including query): " + (afterService - afterAuth) + "ms");
System.out.println("[TIMING] Right before response returned: " + (System.currentTimeMillis() - start) + "ms total so far");
return result;
}
}
@@ -0,0 +1,54 @@
package org.apache.fineract.portfolio.shareaccounts.data;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.organisation.monetary.data.CurrencyData;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountStatusEnumData;
@Getter
public class ShareAccountClientSummaryData implements Serializable {
private final Long id;
private final Long clientId;
private final String clientName;
private final Long officeId;
private final String officeName;
private final String accountNo;
private final Long productId;
private final String productName;
private final Long totalApprovedShares;
private final Long totalPendingForApprovalShares;
private final CurrencyData currency;
private final LocalDate activatedDate;
private final ShareAccountStatusEnumData status;
public ShareAccountClientSummaryData(final Long id, final Long clientId, final String clientName, final Long officeId, final String officeName, final String accountNo,
final Long productId, final String productName, final Long totalApprovedShares, final Long totalPendingForApprovalShares,
final CurrencyData currency, final LocalDate activatedDate, final ShareAccountStatusEnumData status) {
this.id = id;
this.clientId = clientId;
this.clientName = clientName;
this.officeId = officeId;
this.officeName = officeName;
this.accountNo = accountNo;
this.productId = productId;
this.productName = productName;
if (totalApprovedShares == null) {
this.totalApprovedShares = Long.valueOf(0);
} else {
this.totalApprovedShares = totalApprovedShares;
}
if (totalPendingForApprovalShares == null) {
this.totalPendingForApprovalShares = Long.valueOf(0);
} else {
this.totalPendingForApprovalShares = totalPendingForApprovalShares;
}
this.currency = currency;
this.activatedDate = activatedDate;
this.status = status;
}
}
@@ -23,6 +23,7 @@ import java.util.Collection;
import java.util.Set;
import org.apache.fineract.portfolio.accounts.service.AccountReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountData;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountClientSummaryData;
public interface ShareAccountReadPlatformService extends AccountReadPlatformService {
@@ -38,5 +39,7 @@ public interface ShareAccountReadPlatformService extends AccountReadPlatformServ
Collection<ShareAccountData> retrieveAllShareAccountDataForDividends(Long productId, boolean fetchInActiveAccounts,
LocalDate startDate);
Collection<ShareAccountClientSummaryData> retrieveAllShareAccountClientSummaries(String searchTerm);
String retrieveAccountNumberByAccountId(Long accountId);
}
@@ -62,6 +62,7 @@ import org.apache.fineract.portfolio.shareaccounts.domain.ShareAccountStatusType
import org.apache.fineract.portfolio.shareproducts.data.ShareProductData;
import org.apache.fineract.portfolio.shareproducts.data.ShareProductMarketPriceData;
import org.apache.fineract.portfolio.shareproducts.service.ShareProductDropdownReadPlatformService;
import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountClientSummaryData;
import org.springframework.context.ApplicationContext;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -221,6 +222,29 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
}, mapper);
}
@Override
public Collection<ShareAccountClientSummaryData> retrieveAllShareAccountClientSummaries(String searchTerm) {
long start = System.currentTimeMillis();
System.out.println("[TIMING] Inside retrieveAllShareAccountClientSummaries, before query builder setup");
ShareAccountClientSummaryMapper mapper = new ShareAccountClientSummaryMapper();
StringBuilder queryBuilder = new StringBuilder("select ").append(mapper.schema());
List<Object> paramList = new ArrayList<>();
if (searchTerm != null && !searchTerm.trim().isEmpty()) {
queryBuilder.append(" where (c.display_name ILIKE ? OR sa.account_no ILIKE ?)");
String searchPattern = "%" + searchTerm + "%";
paramList.add(searchPattern);
paramList.add(searchPattern);
}
long beforeQuery = System.currentTimeMillis();
System.out.println("[TIMING] Before actual SQL query call. Time since service start: " + (beforeQuery - start) + "ms");
Collection<ShareAccountClientSummaryData> result = this.jdbcTemplate.query(queryBuilder.toString(), mapper, paramList.toArray());
long afterQuery = System.currentTimeMillis();
System.out.println("[TIMING] Query execution (JdbcTemplate): " + (afterQuery - beforeQuery) + "ms");
return result;
}
public Collection<ShareAccountChargeData> convertChargesToShareAccountCharges(Collection<ChargeData> productCharges) {
final Collection<ShareAccountChargeData> savingsCharges = new ArrayList<>();
for (final ChargeData chargeData : productCharges) {
@@ -495,6 +519,63 @@ public class ShareAccountReadPlatformServiceImpl implements ShareAccountReadPlat
}
}
private static final class ShareAccountClientSummaryMapper implements RowMapper<ShareAccountClientSummaryData> {
private final String schema;
ShareAccountClientSummaryMapper() {
StringBuilder buff = new StringBuilder().append("sa.id as id, c.id as clientId, c.display_name as clientName, ")
.append("sa.account_no as accountNo, p.id as productId, p.name as productName, ")
.append("sa.total_approved_shares as approvedShares, sa.total_pending_shares as pendingShares, ")
.append("sa.currency_code as currencyCode, sa.currency_digits as currencyDigits, sa.currency_multiplesof as inMultiplesOf, ")
.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append("curr.display_symbol as currencyDisplaySymbol, sa.activated_date as activatedDate, ")
.append("sa.status_enum as statusEnum, o.id as officeId, o.name as officeName ")
.append("from m_share_account sa ")
.append("inner join m_client c ON c.id = sa.client_id ")
.append("inner join m_office o ON o.id = c.office_id ")
.append("inner join m_share_product p on p.id = sa.product_id ")
.append("inner join m_currency curr on curr.code = sa.currency_code");
this.schema = buff.toString();
}
@Override
public ShareAccountClientSummaryData mapRow(ResultSet rs, @SuppressWarnings("unused") int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long clientId = rs.getLong("clientId");
final String clientName = rs.getString("clientName");
final String accountNo = rs.getString("accountNo");
final Long productId = rs.getLong("productId");
final String productName = rs.getString("productName");
final Long totalApprovedShares = JdbcSupport.getLong(rs, "approvedShares");
final Long totalPendingForApprovalShares = JdbcSupport.getLong(rs, "pendingShares");
final String currencyCode = rs.getString("currencyCode");
final String currencyName = rs.getString("currencyName");
final String currencyNameCode = rs.getString("currencyNameCode");
final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol");
final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits");
final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf");
final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf,
currencyDisplaySymbol, currencyNameCode);
final LocalDate activatedDate = JdbcSupport.getLocalDate(rs, "activatedDate");
final Integer statusEnum = rs.getInt("statusEnum");
final ShareAccountStatusEnumData status = SharesEnumerations.status(statusEnum);
final Long officeId = rs.getLong("officeId");
final String officeName = rs.getString("officeName");
return new ShareAccountClientSummaryData(id, clientId, clientName, officeId, officeName, accountNo, productId, productName,
totalApprovedShares, totalPendingForApprovalShares, currency, activatedDate, status);
}
public String schema() {
return this.schema;
}
}
@Override
public String retrieveAccountNumberByAccountId(Long accountId) {
try {
@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.welcomekit.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.core.MediaType;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
import org.apache.fineract.portfolio.welcomekit.service.WelcomeKitReadPlatformService;
import org.apache.fineract.portfolio.welcomekit.service.WelcomeKitWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clients/{clientId}/welcomekit")
@Component
@Tag(name = "Welcome Kit Dispatch", description = "Manages the dispatching and tracking of welcome kits for clients.")
@RequiredArgsConstructor
public class ClientWelcomeKitApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "WELCOMEKIT";
private final PlatformSecurityContext context;
private final WelcomeKitReadPlatformService readPlatformService;
private final WelcomeKitWritePlatformService writePlatformService;
private final FromJsonHelper fromJsonHelper;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List all welcome kits for a Client")
public ClientWelcomeKitData getClientWelcomeKits(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.readPlatformService.retrieveWelcomeKitsByClientId(clientId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a new welcome kit dispatch")
public CommandProcessingResult dispatchWelcomeKit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_" + RESOURCE_NAME_FOR_PERMISSIONS);
final JsonCommand command = JsonCommand.fromJsonElement(clientId, this.fromJsonHelper.parse(apiRequestBodyAsJson), this.fromJsonHelper);
return this.writePlatformService.dispatchWelcomeKit(clientId, command);
}
}
@@ -0,0 +1,67 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.welcomekit.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 lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
import org.apache.fineract.portfolio.welcomekit.service.WelcomeKitReadPlatformService;
import org.apache.fineract.portfolio.welcomekit.service.WelcomeKitWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/welcomekit")
@Component
@Tag(name = "Welcome Kit Dispatch", description = "Manages the dispatching and tracking of welcome kits for clients.")
@RequiredArgsConstructor
public class WelcomeKitApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "WELCOMEKIT";
private final PlatformSecurityContext context;
private final WelcomeKitWritePlatformService writePlatformService;
private final FromJsonHelper fromJsonHelper;
@PUT
@Path("{welcomeKitId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update the status of an existing dispatch")
public CommandProcessingResult updateWelcomeKitStatus(
@PathParam("welcomeKitId") @Parameter(description = "welcomeKitId") final Long welcomeKitId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("UPDATE_" + RESOURCE_NAME_FOR_PERMISSIONS);
final JsonCommand command = JsonCommand.fromJsonElement(welcomeKitId, this.fromJsonHelper.parse(apiRequestBodyAsJson), this.fromJsonHelper);
return this.writePlatformService.updateWelcomeKitStatus(welcomeKitId, command);
}
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.welcomekit.data;
import java.io.Serializable;
import java.util.List;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ClientWelcomeKitData implements Serializable {
private final String clientName;
private final boolean canDispatchNew;
private final List<WelcomeKitDispatchData> dispatches;
}
@@ -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.welcomekit.data;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class WelcomeKitDispatchData implements Serializable {
private final Long id;
private final Long clientId;
private final String clientName;
private final String deliveryAddress;
private final String deliveryMode;
private final String deliveryModeOtherText;
private final String trackingId;
private final String status;
private final LocalDate dispatchedOnDate;
private final LocalDate deliveredOnDate;
}
@@ -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.welcomekit.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@Table(name = "m_welcome_kit_dispatch")
@EntityListeners(AuditingEntityListener.class)
public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "delivery_address", length = 500, nullable = false)
private String deliveryAddress;
@Column(name = "delivery_mode", length = 50, nullable = false)
private String deliveryMode;
@Column(name = "delivery_mode_other_text", length = 255)
private String deliveryModeOtherText;
@Column(name = "tracking_id", length = 100, nullable = false, unique = true)
private String trackingId;
@Column(name = "status", length = 30, nullable = false)
private String status;
@Column(name = "dispatched_on_date", nullable = false)
private LocalDate dispatchedOnDate;
@Column(name = "delivered_on_date")
private LocalDate deliveredOnDate;
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@CreatedDate
@Column(name = "created_date", updatable = false)
private LocalDateTime createdDate;
@LastModifiedBy
@Column(name = "last_modified_by")
private Long lastModifiedBy;
@LastModifiedDate
@Column(name = "last_modified_date")
private LocalDateTime lastModifiedDate;
protected WelcomeKitDispatch() {
// Default constructor for JPA
}
private WelcomeKitDispatch(final Client client, final String deliveryAddress, final String deliveryMode,
final String deliveryModeOtherText, final String trackingId, final String status,
final LocalDate dispatchedOnDate) {
this.client = client;
this.deliveryAddress = deliveryAddress;
this.deliveryMode = deliveryMode;
this.deliveryModeOtherText = deliveryModeOtherText;
this.trackingId = trackingId;
this.status = status;
this.dispatchedOnDate = dispatchedOnDate;
}
public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final String deliveryMode,
final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
return new WelcomeKitDispatch(client, deliveryAddress, deliveryMode, deliveryModeOtherText, trackingId, "DISPATCHED", dispatchedOnDate);
}
public Client getClient() {
return this.client;
}
public String getStatus() {
return this.status;
}
public void updateStatus(final String newStatus, final LocalDate deliveredOnDate) {
this.status = newStatus;
if ("DELIVERED".equals(newStatus)) {
this.deliveredOnDate = deliveredOnDate;
}
}
}
@@ -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.welcomekit.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface WelcomeKitDispatchRepository extends JpaRepository<WelcomeKitDispatch, Long>, JpaSpecificationExecutor<WelcomeKitDispatch> {
List<WelcomeKitDispatch> findByClientIdOrderByCreatedDateDesc(Long clientId);
}
@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.welcomekit.service;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
public interface WelcomeKitReadPlatformService {
ClientWelcomeKitData retrieveWelcomeKitsByClientId(Long clientId);
}
@@ -0,0 +1,103 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.welcomekit.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
import org.apache.fineract.portfolio.welcomekit.data.WelcomeKitDispatchData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatformService {
private final JdbcTemplate jdbcTemplate;
@Override
public ClientWelcomeKitData retrieveWelcomeKitsByClientId(Long clientId) {
final WelcomeKitDispatchMapper rm = new WelcomeKitDispatchMapper();
final String sql = "SELECT " + rm.schema() + " WHERE w.client_id = ? ORDER BY w.created_date DESC";
List<WelcomeKitDispatchData> dispatches = this.jdbcTemplate.query(sql, rm, clientId);
String clientName = this.jdbcTemplate.queryForObject("SELECT display_name FROM m_client WHERE id = ?", String.class, clientId);
boolean canDispatchNew = true;
if (!dispatches.isEmpty()) {
WelcomeKitDispatchData latest = dispatches.get(0);
if ("RETURNED".equals(latest.getStatus())) {
canDispatchNew = true;
} else {
canDispatchNew = false;
}
}
return ClientWelcomeKitData.builder()
.clientName(clientName)
.canDispatchNew(canDispatchNew)
.dispatches(dispatches)
.build();
}
private static final class WelcomeKitDispatchMapper implements RowMapper<WelcomeKitDispatchData> {
public String schema() {
return " w.id as id, w.client_id as clientId, c.display_name as clientName, "
+ " w.delivery_address as deliveryAddress, w.delivery_mode as deliveryMode, "
+ " w.delivery_mode_other_text as deliveryModeOtherText, w.tracking_id as trackingId, "
+ " w.status as status, w.dispatched_on_date as dispatchedOnDate, w.delivered_on_date as deliveredOnDate "
+ " FROM m_welcome_kit_dispatch w "
+ " JOIN m_client c ON c.id = w.client_id ";
}
@Override
public WelcomeKitDispatchData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final Long clientId = rs.getLong("clientId");
final String clientName = rs.getString("clientName");
final String deliveryAddress = rs.getString("deliveryAddress");
final String deliveryMode = rs.getString("deliveryMode");
final String deliveryModeOtherText = rs.getString("deliveryModeOtherText");
final String trackingId = rs.getString("trackingId");
final String status = rs.getString("status");
final LocalDate dispatchedOnDate = JdbcSupport.getLocalDate(rs, "dispatchedOnDate");
final LocalDate deliveredOnDate = JdbcSupport.getLocalDate(rs, "deliveredOnDate");
return WelcomeKitDispatchData.builder()
.id(id)
.clientId(clientId)
.clientName(clientName)
.deliveryAddress(deliveryAddress)
.deliveryMode(deliveryMode)
.deliveryModeOtherText(deliveryModeOtherText)
.trackingId(trackingId)
.status(status)
.dispatchedOnDate(dispatchedOnDate)
.deliveredOnDate(deliveredOnDate)
.build();
}
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.welcomekit.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface WelcomeKitWritePlatformService {
CommandProcessingResult dispatchWelcomeKit(Long clientId, JsonCommand command);
CommandProcessingResult updateWelcomeKitStatus(Long welcomeKitId, JsonCommand command);
}
@@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT 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.welcomekit.service;
import java.time.LocalDate;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.welcomekit.domain.WelcomeKitDispatch;
import org.apache.fineract.portfolio.welcomekit.domain.WelcomeKitDispatchRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatformService {
private final PlatformSecurityContext context;
private final ClientRepositoryWrapper clientRepository;
private final WelcomeKitDispatchRepository welcomeKitDispatchRepository;
@Transactional
@Override
public CommandProcessingResult dispatchWelcomeKit(final Long clientId, final JsonCommand command) {
this.context.authenticatedUser();
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
final String deliveryAddress = command.stringValueOfParameterNamed("deliveryAddress");
final String deliveryMode = command.stringValueOfParameterNamed("deliveryMode");
final String deliveryModeOtherText = command.stringValueOfParameterNamed("deliveryModeOtherText");
final LocalDate dispatchedOnDate = command.localDateValueOfParameterNamed("dispatchedOnDate");
if (deliveryAddress == null || deliveryAddress.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryAddress.cannot.be.blank",
"Delivery address cannot be blank.");
}
if (deliveryMode == null || deliveryMode.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryMode.cannot.be.blank",
"Delivery mode cannot be blank.");
}
if (dispatchedOnDate == null) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.cannot.be.blank",
"Dispatched on date cannot be blank.");
}
final String trackingId = "WK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, deliveryMode, deliveryModeOtherText,
trackingId, dispatchedOnDate);
this.welcomeKitDispatchRepository.saveAndFlush(newDispatch);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withClientId(clientId)
.withEntityId(newDispatch.getId())
.build();
}
@Transactional
@Override
public CommandProcessingResult updateWelcomeKitStatus(final Long welcomeKitId, final JsonCommand command) {
this.context.authenticatedUser();
final WelcomeKitDispatch dispatch = this.welcomeKitDispatchRepository.findById(welcomeKitId).orElseThrow(
() -> new PlatformDataIntegrityException("error.msg.welcomekit.not.found", "Welcome kit not found."));
final String status = command.stringValueOfParameterNamed("status");
if (status == null || status.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.cannot.be.blank", "Status cannot be blank.");
}
LocalDate deliveredOnDate = null;
if ("DELIVERED".equals(status)) {
deliveredOnDate = LocalDate.now();
}
dispatch.updateStatus(status, deliveredOnDate);
this.welcomeKitDispatchRepository.saveAndFlush(dispatch);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withClientId(dispatch.getClient().getId())
.withEntityId(dispatch.getId())
.build();
}
}
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -435,6 +435,7 @@
<include file="parts/0412_activate_outward_clearing_batch_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0413_inward_item_verify_signature_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0414_inward_item_full_source_details.xml" relativeToChangelogFile="true"/>
<include file="parts/0415_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/>
<include file="parts/0415_add_otp_delivery_method_and_first_login_gate_to_appuser.xml" relativeToChangelogFile="true"/>
<include file="parts/0416_create_user_agreement_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0417_add_first_login_gate_config.xml" relativeToChangelogFile="true"/>
@@ -483,4 +484,11 @@
<include file="parts/0362_add_share_account_edit_approval_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0421_add_master_salutation_value.xml" relativeToChangelogFile="true"/>
<include file="parts/0428_add_client_risk_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0429_add_aml_fields_to_risk_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0430_seed_aml_compliance_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0433_create_aml_transaction_monitoring_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0434_seed_aml_transaction_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0435_add_aml_officer_and_principal_officer_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0436_fix_missing_client_risk_rule_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0437_update_aml_columns_for_risk_rules.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -23,24 +23,32 @@
<!-- Dedicated liability GL account for the Sponsor DD Payable balance owed to SBI, as a child
of the existing "OTHER LIABILITY" account (gl_code 9), matching the pattern already used
by "INTEREST PAYABLE" (gl_code 9.2). -->
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account">
by "INTEREST PAYABLE" (gl_code 9.2).
parent_id is resolved by gl_code (not hardcoded) — the "OTHER LIABILITY" row's numeric id
is an auto-increment value that depends on each tenant's own insert history, so a
hardcoded id (previously 22) only happened to match the `default` tenant and broke FK
inserts on any tenant whose chart of accounts was seeded differently (e.g. `saga`), which
aborted that tenant's whole Liquibase run — and with it, the entire application startup.
runOnChange lets this corrected version safely re-validate on tenants where the old,
already-recorded version succeeded: the "gl_code 9.3 doesn't exist yet" precondition below
is already false there, so it's skipped rather than re-inserted. -->
<changeSet author="fineract" id="0345-1-insert-sponsor-dd-payable-sbi-gl-account" runOnChange="true">
<preConditions onFail="MARK_RAN">
<not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
</not>
<and>
<not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9.3'</sqlCheck>
</not>
<sqlCheck expectedResult="1">select count(*) from acc_gl_account where gl_code = '9'</sqlCheck>
</and>
</preConditions>
<insert tableName="acc_gl_account">
<column name="name" value="Sponsor DD Payable - SBI"/>
<column name="gl_code" value="9.3"/>
<column name="parent_id" valueNumeric="22"/>
<column name="hierarchy" value="."/>
<column name="account_usage" valueNumeric="1"/>
<column name="classification_enum" valueNumeric="2"/>
<column name="disabled" valueBoolean="false"/>
<column name="manual_journal_entries_allowed" valueBoolean="true"/>
<column name="description" value="Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing."/>
</insert>
<sql>
INSERT INTO acc_gl_account (name, gl_code, parent_id, hierarchy, account_usage, classification_enum, disabled, manual_journal_entries_allowed, description)
SELECT 'Sponsor DD Payable - SBI', '9.3', parent.id, '.', 1, 2, FALSE, TRUE,
'Liability owed to SBI for sponsor bank demand drafts issued on their behalf, pending clearing.'
FROM acc_gl_account parent
WHERE parent.gl_code = '9';
</sql>
</changeSet>
<!-- Fineract's GL hierarchy path embeds the row's own auto-generated id, so it can only be
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0415_welcome_kit_dispatch" author="CBS" runOnChange="false">
<sql>
CREATE TABLE m_welcome_kit_dispatch (
id BIGSERIAL PRIMARY KEY,
client_id BIGINT NOT NULL REFERENCES m_client(id),
delivery_address VARCHAR(500) NOT NULL,
delivery_mode VARCHAR(50) NOT NULL,
delivery_mode_other_text VARCHAR(255),
tracking_id VARCHAR(100) NOT NULL UNIQUE,
status VARCHAR(30) NOT NULL DEFAULT 'DISPATCHED',
dispatched_on_date DATE NOT NULL,
delivered_on_date DATE,
created_by BIGINT,
created_date TIMESTAMP NOT NULL DEFAULT NOW(),
last_modified_by BIGINT,
last_modified_date TIMESTAMP
);
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'READ_WELCOMEKIT', 'WELCOMEKIT', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_WELCOMEKIT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_WELCOMEKIT', 'WELCOMEKIT', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_WELCOMEKIT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'UPDATE_WELCOMEKIT', 'WELCOMEKIT', 'UPDATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_WELCOMEKIT');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<changeSet author="fineract" id="0429-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_risk_rule" columnName="aml_check_status"/></not>
</preConditions>
<addColumn tableName="m_client_risk_rule">
<column name="aml_check_status" type="VARCHAR(20)"/>
<column name="banned_entity_match" type="VARCHAR(10)"/>
<column name="willful_defaulter" type="VARCHAR(10)"/>
<column name="ecgc_sal_flag" type="VARCHAR(10)"/>
<column name="dormant_flag" type="VARCHAR(20)"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0429-2">
<modifyDataType tableName="m_client_risk_rule" columnName="rule_name" newDataType="VARCHAR(500)"/>
<modifyDataType tableName="m_client_risk_rule" columnName="reason" newDataType="VARCHAR(500)"/>
</changeSet>
</databaseChangeLog>

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