Compare commits

...
Author SHA1 Message Date
tarunmalviya 37c961f713 DDS: Agent settlement (Agent Tran), Close Party account, reports (Supplementary-DDS/Master Data Export/Close Register), Party-Savings link, product category field
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract 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 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 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-08-25 10:33:37 +05:30
keshavandClaude Opus 5 9eac40bb5c feat: add database export API, welcome kit return tracking, and sanctions/mobile lookup updates
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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - 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 - 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 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 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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:55:51 +05:30
niteshandClaude Sonnet 5 14b2585cf5 feat(loan-origination): add full backend persistence for the sanctioning pipeline
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Adds a lightweight loan-origination-pipeline module (application, stage history,
approvals, FI checks and documents) with server-side validation per stage
transition, so every field, upload and decision on the Loan Origination screen
survives a page refresh instead of living only in the browser.

Also fixes two content-store bugs found while wiring document uploads through
the generic Fineract document store: the CSV mime/extension whitelist rejected
bank-statement uploads outright, and the post-upload mime consistency check
rejected any CSV whose byte-content sniffs as text/plain (which is all of them,
since CSV has no distinguishing magic bytes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 17:12:43 +05:30
Anshul Rathore a2fa8688ae Update client KYC and AML changes
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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-08-14 12:54:43 +05:30
keshav a8ca7dd948 feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
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 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 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-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 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
- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports.
- Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons.
- Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars.
- Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval.
- Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names.
- Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks.
- Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
2026-08-14 10:46:59 +05:30
niteshandClaude Sonnet 5 c8833367b9 feat(shares): route dividend cash claims through cashier token disbursement
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 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 - 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 E2E Tests / E2E Tests (Shard 6 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
Cash claims now mirror the existing clerk-withdrawal token flow: clerk
submits with a token, manager approval posts the GL entry, and a cashier
redeems the token at Transaction Entry to record physical hand-over --
no direct manager-to-member cash handover, and no double-disbursement.

Also fixes GL accounting for the loan/savings legs: instead of reversing
the native command's own debit with a second manual entry pair (leaving
the fund-source account confusingly appearing twice), that debit leg is
now rewritten in place to Dividend Payable. Floors cash claims to whole
rupees since notes/coins can't represent paise, with backward-compatible
tolerance for claims submitted before the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 10:17:35 +05:30
nitesh 94b0afff38 Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract 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 Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (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 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-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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
2026-08-11 12:53:20 +05:30
niteshandClaude Sonnet 5 9dd4f8bedb feat(shares): real-profit driven dividends with GL-only liability model and member claim workflow
Reworks the share dividend lifecycle end to end:
- Dividend amount now auto-computed from real GL profit x officer-entered percentage
  instead of manual entry; adds a Dividend Paid report over the existing Dividend
  Payable ledger.
- Declaration posts a real GL entry (Dr Reserve for Dividend / Cr Dividend Payable);
  approval no longer credits any savings account up front -- every member's
  entitlement stays a pure "Dividend Payable" liability until claimed, resolved
  synchronously at approval time (no background job or per-member failure mode).
- New maker-checker Dividend Claim Request workflow: a clerk records how a member
  wants their unclaimed dividend split (overdue loan / savings credit / cash), a
  manager approves, which executes native repayment/deposit commands and replenishes
  whichever GL account they actually debited (not a hardcoded assumption) from the
  Dividend Payable liability.
- Unclaimed dividends carry a 3-year window (m_share_dividend_lock) after which a
  daily job forfeits them straight to profit; a Dividend Dashboard consolidates
  summary stats, every declaration's status, full lock history, and proactive
  loan-application for members with overdue EMIs.
- Fixes a native negative-share-days bug in ShareProductDividendAssembler and adds
  overlap-period validation so a product can't have two declarations covering the
  same days.
- Removes the now-unreachable failed/pending-payout tracking that the old
  per-member job-based model needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 12:42:45 +05:30
186 changed files with 15384 additions and 137 deletions
@@ -436,7 +436,9 @@ public final class AccountingConstants {
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), // CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), // OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), // ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); // PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
private final Integer value; private final Integer value;
private final String code; private final String code;
@@ -664,6 +664,78 @@ public class CommandWrapperBuilder {
return this; return this;
} }
public CommandWrapperBuilder createAgent() {
this.actionName = "CREATE";
this.entityName = "AGENT";
this.entityId = null;
this.href = "/dds/agents/template";
return this;
}
public CommandWrapperBuilder updateAgent(final Long agentId) {
this.actionName = "UPDATE";
this.entityName = "AGENT";
this.entityId = agentId;
this.href = "/dds/agents/" + agentId;
return this;
}
public CommandWrapperBuilder createParty() {
this.actionName = "CREATE";
this.entityName = "PARTY";
this.entityId = null;
this.href = "/dds/parties/template";
return this;
}
public CommandWrapperBuilder updateParty(final Long partyId) {
this.actionName = "UPDATE";
this.entityName = "PARTY";
this.entityId = partyId;
this.href = "/dds/parties/" + partyId;
return this;
}
public CommandWrapperBuilder closeParty(final Long partyId) {
this.actionName = "CLOSE";
this.entityName = "PARTY";
this.entityId = partyId;
this.href = "/dds/parties/" + partyId + "?command=close";
return this;
}
public CommandWrapperBuilder createPartyTransaction() {
this.actionName = "CREATE";
this.entityName = "PARTYTRANSACTION";
this.entityId = null;
this.href = "/dds/partytransactions/template";
return this;
}
public CommandWrapperBuilder createCommissionSlabs() {
this.actionName = "CREATE";
this.entityName = "COMMISSIONSLAB";
this.entityId = null;
this.href = "/dds/commissionslabs/template";
return this;
}
public CommandWrapperBuilder generateDdsBatchRun() {
this.actionName = "GENERATE";
this.entityName = "DDSBATCH";
this.entityId = null;
this.href = "/dds/batchruns/template";
return this;
}
public CommandWrapperBuilder postDdsBatchRun(final Long batchRunId) {
this.actionName = "POST";
this.entityName = "DDSBATCH";
this.entityId = batchRunId;
this.href = "/dds/batchruns/" + batchRunId;
return this;
}
public CommandWrapperBuilder createOfficeTransaction() { public CommandWrapperBuilder createOfficeTransaction() {
this.actionName = "CREATE"; this.actionName = "CREATE";
this.entityName = "OFFICETRANSACTION"; this.entityName = "OFFICETRANSACTION";
@@ -38,6 +38,7 @@ public enum JobName {
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), // GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), // GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), // POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS( ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), // "Add Accrual Transactions For Loans With Income Posted As Transactions"), //
@@ -0,0 +1,148 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Getter;
/**
* Immutable data object representing a DDS agent, including its collection-limit history and
* guarantors. Carries the collection/commission/security-account fields merged in from SubAgent
* (2026-08-24) - Agent now does the daily collection work directly.
*/
@Getter
public final class AgentData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String clientName;
private final String agentCode;
private final String agentNameId;
private final String agentAddress1;
private final String agentAddress2;
private final String agentCity;
private final String agentPinCode;
private final String agentTelephone;
private final String agentSavingAccountType;
private final String agentSavingAccountNo;
private final LocalDate agentOpeningDate;
private final LocalDate agentBirthDate;
private final BigDecimal agentCurrentBalance;
private final BigDecimal agentOpeningBalance;
private final String homeBranch;
private final String savingBranch;
private final Boolean tdsApplicable;
private final BigDecimal tdsPercent;
private final BigDecimal currentTds;
private final BigDecimal tdsAmount;
private final String transferBranch;
private final String transferAmountAccountType;
private final String transferAccountNo;
private final Boolean commissionCalculation;
private final BigDecimal limit;
private final BigDecimal machineRent;
private final String agentSecurityBranch;
private final String agentSecurityAccountType;
private final String agentSecurityAccountNo;
private final BigDecimal commission;
private final BigDecimal otherCommission;
private final BigDecimal agentDeposit;
private final BigDecimal festivalAdvance;
private final BigDecimal otherDeduction;
private final Collection<AgentLimitData> limits;
private final Collection<AgentGuarantorData> guarantors;
public static AgentData instance(final Long id, final Long clientId, final String clientName, final String agentCode,
final String agentNameId, final String agentAddress1, final String agentAddress2, final String agentCity,
final String agentPinCode, final String agentTelephone, final String agentSavingAccountType,
final String agentSavingAccountNo, final LocalDate agentOpeningDate, final LocalDate agentBirthDate,
final BigDecimal agentCurrentBalance, final BigDecimal agentOpeningBalance, final String homeBranch,
final String savingBranch, final Boolean tdsApplicable, final BigDecimal tdsPercent, final BigDecimal currentTds,
final BigDecimal tdsAmount, final String transferBranch, final String transferAmountAccountType,
final String transferAccountNo, final Boolean commissionCalculation, final BigDecimal limit, final BigDecimal machineRent,
final String agentSecurityBranch, final String agentSecurityAccountType, final String agentSecurityAccountNo,
final BigDecimal commission, final BigDecimal otherCommission, final BigDecimal agentDeposit,
final BigDecimal festivalAdvance, final BigDecimal otherDeduction, final Collection<AgentLimitData> limits,
final Collection<AgentGuarantorData> guarantors) {
return new AgentData(id, clientId, clientName, agentCode, agentNameId, agentAddress1, agentAddress2, agentCity, agentPinCode,
agentTelephone, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentCurrentBalance,
agentOpeningBalance, homeBranch, savingBranch, tdsApplicable, tdsPercent, currentTds, tdsAmount, transferBranch,
transferAmountAccountType, transferAccountNo, commissionCalculation, limit, machineRent, agentSecurityBranch,
agentSecurityAccountType, agentSecurityAccountNo, commission, otherCommission, agentDeposit, festivalAdvance,
otherDeduction, limits, guarantors);
}
private AgentData(final Long id, final Long clientId, final String clientName, final String agentCode, final String agentNameId,
final String agentAddress1, final String agentAddress2, final String agentCity, final String agentPinCode,
final String agentTelephone, final String agentSavingAccountType, final String agentSavingAccountNo,
final LocalDate agentOpeningDate, final LocalDate agentBirthDate, final BigDecimal agentCurrentBalance,
final BigDecimal agentOpeningBalance, final String homeBranch, final String savingBranch, final Boolean tdsApplicable,
final BigDecimal tdsPercent, final BigDecimal currentTds, final BigDecimal tdsAmount, final String transferBranch,
final String transferAmountAccountType, final String transferAccountNo, final Boolean commissionCalculation,
final BigDecimal limit, final BigDecimal machineRent, final String agentSecurityBranch,
final String agentSecurityAccountType, final String agentSecurityAccountNo, final BigDecimal commission,
final BigDecimal otherCommission, final BigDecimal agentDeposit, final BigDecimal festivalAdvance,
final BigDecimal otherDeduction, final Collection<AgentLimitData> limits, final Collection<AgentGuarantorData> guarantors) {
this.id = id;
this.clientId = clientId;
this.clientName = clientName;
this.agentCode = agentCode;
this.agentNameId = agentNameId;
this.agentAddress1 = agentAddress1;
this.agentAddress2 = agentAddress2;
this.agentCity = agentCity;
this.agentPinCode = agentPinCode;
this.agentTelephone = agentTelephone;
this.agentSavingAccountType = agentSavingAccountType;
this.agentSavingAccountNo = agentSavingAccountNo;
this.agentOpeningDate = agentOpeningDate;
this.agentBirthDate = agentBirthDate;
this.agentCurrentBalance = agentCurrentBalance;
this.agentOpeningBalance = agentOpeningBalance;
this.homeBranch = homeBranch;
this.savingBranch = savingBranch;
this.tdsApplicable = tdsApplicable;
this.tdsPercent = tdsPercent;
this.currentTds = currentTds;
this.tdsAmount = tdsAmount;
this.transferBranch = transferBranch;
this.transferAmountAccountType = transferAmountAccountType;
this.transferAccountNo = transferAccountNo;
this.commissionCalculation = commissionCalculation;
this.limit = limit;
this.machineRent = machineRent;
this.agentSecurityBranch = agentSecurityBranch;
this.agentSecurityAccountType = agentSecurityAccountType;
this.agentSecurityAccountNo = agentSecurityAccountNo;
this.commission = commission;
this.otherCommission = otherCommission;
this.agentDeposit = agentDeposit;
this.festivalAdvance = festivalAdvance;
this.otherDeduction = otherDeduction;
this.limits = limits;
this.guarantors = guarantors;
}
}
@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.data;
import java.io.Serial;
import java.io.Serializable;
import lombok.Getter;
/**
* Immutable data object representing a single agent guarantor.
*/
@Getter
public final class AgentGuarantorData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String guarantorName;
private final String address1;
private final String address2;
private final String city;
private final String pinCode;
private final String telephone;
public static AgentGuarantorData instance(final Long id, final Long clientId, final String guarantorName, final String address1,
final String address2, final String city, final String pinCode, final String telephone) {
return new AgentGuarantorData(id, clientId, guarantorName, address1, address2, city, pinCode, telephone);
}
private AgentGuarantorData(final Long id, final Long clientId, final String guarantorName, final String address1,
final String address2, final String city, final String pinCode, final String telephone) {
this.id = id;
this.clientId = clientId;
this.guarantorName = guarantorName;
this.address1 = address1;
this.address2 = address2;
this.city = city;
this.pinCode = pinCode;
this.telephone = telephone;
}
}
@@ -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.organisation.agent.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
@Getter
public final class AgentLimitData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final LocalDate fromDate;
private final LocalDate toDate;
private final BigDecimal limit;
public static AgentLimitData instance(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
return new AgentLimitData(id, fromDate, toDate, limit);
}
private AgentLimitData(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
this.id = id;
this.fromDate = fromDate;
this.toDate = toDate;
this.limit = limit;
}
}
@@ -0,0 +1,440 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* A DDS (Daily Deposit Scheme) collection agent. Can represent either a person or, at the top of the
* hierarchy, the bank itself acting as its own agent. Holds its own settlement savings account and one
* or more guarantors.
*/
@Getter
@Entity
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
public class Agent extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "agent_code", length = 20, nullable = false)
private String agentCode;
@Column(name = "agent_name", length = 100, nullable = false)
private String agentName;
@Column(name = "agent_address1", length = 200, nullable = false)
private String agentAddress1;
@Column(name = "agent_address2", length = 200)
private String agentAddress2;
@Column(name = "agent_city", length = 100, nullable = false)
private String agentCity;
@Column(name = "agent_pincode", length = 20, nullable = false)
private String agentPinCode;
@Column(name = "agent_telephone", length = 20)
private String agentTelephone;
@Column(name = "agent_saving_account_type", length = 100, nullable = false)
private String agentSavingAccountType;
@Column(name = "agent_saving_account_no", length = 50, nullable = false)
private String agentSavingAccountNo;
@Column(name = "agent_opening_date", nullable = false)
private LocalDate agentOpeningDate;
@Column(name = "agent_birth_date", nullable = false)
private LocalDate agentBirthDate;
@Column(name = "agent_current_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal agentCurrentBalance;
@Column(name = "agent_opening_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal agentOpeningBalance;
// Fields below were merged in from SubAgent (2026-08-24) - the Agent itself now does the daily
// collection work directly; SubAgent no longer exists as a separate tier. Ag.Level / Upper Ag.Level
// / Collector (MLM) / CRPB Points / Level Update Date were dropped - they only ever meant anything
// for a multi-level hierarchy, which no longer exists here.
@Column(name = "home_branch", length = 100, nullable = false)
private String homeBranch;
@Column(name = "saving_branch", length = 100, nullable = false)
private String savingBranch;
@Column(name = "tds_applicable", nullable = false)
private boolean tdsApplicable;
@Column(name = "tds_percent", scale = 6, precision = 19)
private BigDecimal tdsPercent;
@Column(name = "current_tds", scale = 6, precision = 19, nullable = false)
private BigDecimal currentTds;
@Column(name = "tds_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal tdsAmount;
@Column(name = "transfer_branch", length = 100)
private String transferBranch;
@Column(name = "transfer_amount_account_type", length = 100)
private String transferAmountAccountType;
@Column(name = "transfer_account_no", length = 50)
private String transferAccountNo;
@Column(name = "collector_commission_calculation", nullable = false)
private boolean commissionCalculation;
@Column(name = "collector_limit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal limitAmount;
@Column(name = "machine_rent", scale = 6, precision = 19, nullable = false)
private BigDecimal machineRent;
@Column(name = "agent_security_branch", length = 100)
private String agentSecurityBranch;
@Column(name = "agent_security_account_type", length = 100)
private String agentSecurityAccountType;
@Column(name = "agent_security_account_no", length = 50)
private String agentSecurityAccountNo;
@Column(name = "commission", scale = 6, precision = 19, nullable = false)
private BigDecimal commission;
@Column(name = "other_commission", scale = 6, precision = 19, nullable = false)
private BigDecimal otherCommission;
@Column(name = "agent_deposit", scale = 6, precision = 19, nullable = false)
private BigDecimal agentDeposit;
@Column(name = "festival_advance", scale = 6, precision = 19, nullable = false)
private BigDecimal festivalAdvance;
@Column(name = "other_deduction", scale = 6, precision = 19, nullable = false)
private BigDecimal otherDeduction;
@Column(name = "last_commission_date")
private LocalDate lastCommissionDate;
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<AgentGuarantor> guarantors = new ArrayList<>();
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<AgentLimit> limits = new ArrayList<>();
protected Agent() {
//
}
private Agent(final Client client, final String agentCode, final String agentName, final String agentAddress1,
final String agentAddress2, final String agentCity, final String agentPinCode, final String agentTelephone,
final String agentSavingAccountType, final String agentSavingAccountNo, final LocalDate agentOpeningDate,
final LocalDate agentBirthDate, final BigDecimal agentOpeningBalance, final JsonCommand command) {
this.client = client;
this.agentCode = agentCode;
this.agentName = agentName;
this.agentAddress1 = agentAddress1;
this.agentAddress2 = StringUtils.defaultIfEmpty(agentAddress2, null);
this.agentCity = agentCity;
this.agentPinCode = agentPinCode;
this.agentTelephone = StringUtils.defaultIfEmpty(agentTelephone, null);
this.agentSavingAccountType = agentSavingAccountType;
this.agentSavingAccountNo = agentSavingAccountNo;
this.agentOpeningDate = agentOpeningDate;
this.agentBirthDate = agentBirthDate;
this.agentOpeningBalance = agentOpeningBalance == null ? BigDecimal.ZERO : agentOpeningBalance;
this.agentCurrentBalance = this.agentOpeningBalance;
this.homeBranch = command.stringValueOfParameterNamed("homeBranch");
this.savingBranch = command.stringValueOfParameterNamed("savingBranch");
this.tdsApplicable = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
this.tdsPercent = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("tdsPercent");
this.currentTds = BigDecimal.ZERO;
this.tdsAmount = BigDecimal.ZERO;
this.transferBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferBranch"), null);
this.transferAmountAccountType = StringUtils
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAmountAccountType"), null);
this.transferAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAccountNo"), null);
this.commissionCalculation = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
this.limitAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("limit"));
this.machineRent = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("machineRent"));
this.agentSecurityBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityBranch"),
null);
this.agentSecurityAccountType = StringUtils
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountType"), null);
this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountNo"),
null);
this.commission = BigDecimal.ZERO;
this.otherCommission = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherCommission"));
this.agentDeposit = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentDeposit"));
this.festivalAdvance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("festivalAdvance"));
this.otherDeduction = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherDeduction"));
}
public static Agent fromJson(final Client client, final String agentCode, final JsonCommand command) {
final String agentName = command.stringValueOfParameterNamed("agentNameId");
final String agentAddress1 = command.stringValueOfParameterNamed("agentAddress1");
final String agentAddress2 = command.stringValueOfParameterNamedAllowingNull("agentAddress2");
final String agentCity = command.stringValueOfParameterNamed("agentCity");
final String agentPinCode = command.stringValueOfParameterNamed("agentPinCode");
final String agentTelephone = command.stringValueOfParameterNamedAllowingNull("agentTelephone");
final String agentSavingAccountType = command.stringValueOfParameterNamed("agentSavingAccountType");
final String agentSavingAccountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
final LocalDate agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
final LocalDate agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
final BigDecimal agentOpeningBalance = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentOpeningBalance");
return new Agent(client, agentCode, agentName, agentAddress1, agentAddress2, agentCity, agentPinCode, agentTelephone,
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, command);
}
private static BigDecimal orZero(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
public void addGuarantor(final AgentGuarantor guarantor) {
guarantor.setAgent(this);
this.guarantors.add(guarantor);
}
public void resetGuarantors() {
this.guarantors.clear();
}
public void addLimit(final AgentLimit limit) {
limit.setAgent(this);
this.limits.add(limit);
}
public void resetLimits() {
this.limits.clear();
}
/**
* Adds a Commission Posting's amount to this agent's running commission total and stamps the date
* commission was last calculated through, so the next Commission Calculation batch only covers
* collections since then.
*/
public void postCommission(final BigDecimal amount, final LocalDate throughDate) {
this.commission = this.commission.add(amount);
this.lastCommissionDate = throughDate;
}
/**
* Cash this agent has collected from parties but not yet handed over to a branch (an Agent Tran
* settlement). Raised by a Party Tran collection, lowered by an Agent Tran settlement - this is the
* figure an Agent's collection limit is enforced against.
*/
public BigDecimal credit(final BigDecimal amount) {
this.agentCurrentBalance = this.agentCurrentBalance.add(amount);
return this.agentCurrentBalance;
}
public BigDecimal settle(final BigDecimal amount) {
this.agentCurrentBalance = this.agentCurrentBalance.subtract(amount);
return this.agentCurrentBalance;
}
public void changeClient(final Client newClient) {
this.client = newClient;
}
public Long clientId() {
return this.client.getId();
}
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(10);
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
}
if (command.isChangeInStringParameterNamed("agentNameId", this.agentName)) {
final String newValue = command.stringValueOfParameterNamed("agentNameId");
actualChanges.put("agentNameId", newValue);
this.agentName = newValue;
}
if (command.isChangeInStringParameterNamed("agentAddress1", this.agentAddress1)) {
final String newValue = command.stringValueOfParameterNamed("agentAddress1");
actualChanges.put("agentAddress1", newValue);
this.agentAddress1 = newValue;
}
if (command.isChangeInStringParameterNamed("agentAddress2", this.agentAddress2)) {
final String newValue = command.stringValueOfParameterNamed("agentAddress2");
actualChanges.put("agentAddress2", newValue);
this.agentAddress2 = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInStringParameterNamed("agentCity", this.agentCity)) {
final String newValue = command.stringValueOfParameterNamed("agentCity");
actualChanges.put("agentCity", newValue);
this.agentCity = newValue;
}
if (command.isChangeInStringParameterNamed("agentPinCode", this.agentPinCode)) {
final String newValue = command.stringValueOfParameterNamed("agentPinCode");
actualChanges.put("agentPinCode", newValue);
this.agentPinCode = newValue;
}
if (command.isChangeInStringParameterNamed("agentTelephone", this.agentTelephone)) {
final String newValue = command.stringValueOfParameterNamed("agentTelephone");
actualChanges.put("agentTelephone", newValue);
this.agentTelephone = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInStringParameterNamed("agentSavingAccountType", this.agentSavingAccountType)) {
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountType");
actualChanges.put("agentSavingAccountType", newValue);
this.agentSavingAccountType = newValue;
}
if (command.isChangeInStringParameterNamed("agentSavingAccountNo", this.agentSavingAccountNo)) {
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountNo");
actualChanges.put("agentSavingAccountNo", newValue);
this.agentSavingAccountNo = newValue;
}
if (command.isChangeInDateParameterNamed("agentOpeningDate", this.agentOpeningDate)) {
actualChanges.put("agentOpeningDate", command.stringValueOfParameterNamed("agentOpeningDate"));
this.agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
}
if (command.isChangeInDateParameterNamed("agentBirthDate", this.agentBirthDate)) {
actualChanges.put("agentBirthDate", command.stringValueOfParameterNamed("agentBirthDate"));
this.agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
}
stringChange(command, actualChanges, "homeBranch", this.homeBranch, v -> this.homeBranch = v);
stringChange(command, actualChanges, "savingBranch", this.savingBranch, v -> this.savingBranch = v);
if (command.isChangeInBooleanParameterNamed("tdsApplicable", this.tdsApplicable)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
actualChanges.put("tdsApplicable", newValue);
this.tdsApplicable = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("tdsPercent", this.tdsPercent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("tdsPercent");
actualChanges.put("tdsPercent", newValue);
this.tdsPercent = newValue;
}
stringChange(command, actualChanges, "transferBranch", this.transferBranch,
v -> this.transferBranch = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "transferAmountAccountType", this.transferAmountAccountType,
v -> this.transferAmountAccountType = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "transferAccountNo", this.transferAccountNo,
v -> this.transferAccountNo = StringUtils.defaultIfEmpty(v, null));
if (command.isChangeInBooleanParameterNamed("commissionCalculation", this.commissionCalculation)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
actualChanges.put("commissionCalculation", newValue);
this.commissionCalculation = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("limit", this.limitAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("limit");
actualChanges.put("limit", newValue);
this.limitAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("machineRent", this.machineRent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("machineRent");
actualChanges.put("machineRent", newValue);
this.machineRent = newValue;
}
stringChange(command, actualChanges, "agentSecurityBranch", this.agentSecurityBranch,
v -> this.agentSecurityBranch = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "agentSecurityAccountType", this.agentSecurityAccountType,
v -> this.agentSecurityAccountType = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "agentSecurityAccountNo", this.agentSecurityAccountNo,
v -> this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(v, null));
if (command.isChangeInBigDecimalParameterNamed("otherCommission", this.otherCommission)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherCommission");
actualChanges.put("otherCommission", newValue);
this.otherCommission = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("agentDeposit", this.agentDeposit)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("agentDeposit");
actualChanges.put("agentDeposit", newValue);
this.agentDeposit = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("festivalAdvance", this.festivalAdvance)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("festivalAdvance");
actualChanges.put("festivalAdvance", newValue);
this.festivalAdvance = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("otherDeduction", this.otherDeduction)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherDeduction");
actualChanges.put("otherDeduction", newValue);
this.otherDeduction = newValue;
}
return actualChanges;
}
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
final String existingValue, final java.util.function.Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
final String newValue = command.stringValueOfParameterNamed(paramName);
actualChanges.put(paramName, newValue);
setter.accept(newValue);
}
}
}
@@ -0,0 +1,83 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A single guarantor recorded against an {@link Agent} (or, reused with the same shape, against a
* SubAgent). Always created/replaced as part of its parent agent's own create/update command - it has
* no independent API of its own.
*/
@Getter
@Entity
@Table(name = "m_dds_agent_guarantor")
public class AgentGuarantor extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "guarantor_name", length = 100, nullable = false)
private String guarantorName;
@Column(name = "address1", length = 200, nullable = false)
private String address1;
@Column(name = "address2", length = 200)
private String address2;
@Column(name = "city", length = 100, nullable = false)
private String city;
@Column(name = "pin_code", length = 20, nullable = false)
private String pinCode;
@Column(name = "telephone", length = 20)
private String telephone;
protected AgentGuarantor() {
//
}
public AgentGuarantor(final Long clientId, final String guarantorName, final String address1, final String address2,
final String city, final String pinCode, final String telephone) {
this.clientId = clientId;
this.guarantorName = guarantorName;
this.address1 = address1;
this.address2 = StringUtils.defaultIfEmpty(address2, null);
this.city = city;
this.pinCode = pinCode;
this.telephone = StringUtils.defaultIfEmpty(telephone, null);
}
void setAgent(final Agent agent) {
this.agent = agent;
}
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row of an Agent's time-bound collection-limit history (From Date / To Date / Limit) - an
* Agent's authorized collection limit can change over time and every change is kept, not overwritten.
*/
@Getter
@Entity
@Table(name = "m_dds_agent_limit")
public class AgentLimit extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "from_date", nullable = false)
private LocalDate fromDate;
@Column(name = "to_date", nullable = false)
private LocalDate toDate;
@Column(name = "limit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal limitAmount;
protected AgentLimit() {
//
}
public AgentLimit(final LocalDate fromDate, final LocalDate toDate, final BigDecimal limitAmount) {
this.fromDate = fromDate;
this.toDate = toDate;
this.limitAmount = limitAmount;
}
void setAgent(final Agent agent) {
this.agent = agent;
}
}
@@ -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.organisation.agent.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* A {@link RuntimeException} thrown when a DDS agent resource is not found.
*/
public class AgentNotFoundException extends AbstractPlatformResourceNotFoundException {
public AgentNotFoundException(final Long id) {
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id);
}
public AgentNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id, e);
}
public AgentNotFoundException(final String agentCode) {
super("error.msg.agent.code.invalid", "Agent with code " + agentCode + " does not exist", agentCode);
}
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class CommissionSlabData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final String slabType;
private final BigDecimal slabFrom;
private final BigDecimal slabTo;
private final BigDecimal commissionPercent;
}
@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row of the agent commission chart: an ACCOUNT slab (commission percentage on collection
* volume) or a COMMISSION slab (the override a senior agent earns on a sub-agent's commission).
* Append-only reference data, like the legacy screen it replaces - there is deliberately no update or
* delete API; a superseded slab range is retired by adding a new row, not editing history.
*/
@Getter
@Entity
@Table(name = "m_dds_commission_slab")
public class CommissionSlab extends AbstractPersistableCustom<Long> {
@Column(name = "slab_type", length = 20, nullable = false)
private String slabType;
@Column(name = "slab_from", scale = 6, precision = 19, nullable = false)
private BigDecimal slabFrom;
@Column(name = "slab_to", scale = 6, precision = 19, nullable = false)
private BigDecimal slabTo;
@Column(name = "commission_percent", scale = 6, precision = 19, nullable = false)
private BigDecimal commissionPercent;
protected CommissionSlab() {
//
}
public CommissionSlab(final String slabType, final BigDecimal slabFrom, final BigDecimal slabTo, final BigDecimal commissionPercent) {
this.slabType = slabType;
this.slabFrom = slabFrom;
this.slabTo = slabTo;
this.commissionPercent = commissionPercent;
}
}
@@ -191,6 +191,7 @@ public class ClientApiConstants {
public static final String constitutionIdParamName = "constitutionId"; public static final String constitutionIdParamName = "constitutionId";
public static final String mainBusinessLineIdParamName = "mainBusinessLineId"; public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
public static final String authorizedSignatoryParamName = "authorizedSignatory"; public static final String authorizedSignatoryParamName = "authorizedSignatory";
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
public static final String additionalInfoParamName = "additionalInfo"; public static final String additionalInfoParamName = "additionalInfo";
public static final String dateOfRegistrationParamName = "dateOfRegistration"; public static final String dateOfRegistrationParamName = "dateOfRegistration";
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
private Boolean alternateDipVerified; private Boolean alternateDipVerified;
private Long alternateDipLookupLogId; private Long alternateDipLookupLogId;
private String alternateDipTimestamp; private String alternateDipTimestamp;
/**
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
* reassigned would otherwise keep showing "Verified" forever.
*/
private String dipLiveStatus;
private String alternateDipLiveStatus;
/**
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
* would keep reading "Clear" after the client was added to a later list snapshot.
*/
private String sanctionsAlertStatus;
private Boolean nameVerified; private Boolean nameVerified;
private Long screeningLogId; private Long screeningLogId;
private Boolean isStaff; private Boolean isStaff;
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified; data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId; data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp; data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified; data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId; data.screeningLogId = clientData.screeningLogId;
return data; return data;
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified; data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId; data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp; data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified; data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId; data.screeningLogId = clientData.screeningLogId;
return data; return data;
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified; data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId; data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp; data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified; data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId; data.screeningLogId = clientData.screeningLogId;
return data; return data;
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
private final CodeValueData mainBusinessLine; private final CodeValueData mainBusinessLine;
private final String remarks; private final String remarks;
private final String authorizedSignatory; private final String authorizedSignatory;
/** Client ids behind {@link #authorizedSignatory}, comma separated and in the same order. */
private final String authorizedSignatoryClientIds;
private final String additionalInfo; private final String additionalInfo;
private final LocalDate dateOfRegistration; private final LocalDate dateOfRegistration;
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
this.constitution = null; this.constitution = null;
this.mainBusinessLine = null; this.mainBusinessLine = null;
this.authorizedSignatory = null; this.authorizedSignatory = null;
this.authorizedSignatoryClientIds = null;
this.additionalInfo = null; this.additionalInfo = null;
this.dateOfRegistration = null; this.dateOfRegistration = null;
} }
/**
* Kept for the list/search mappers, whose SQL does not select the signatory client ids.
*/
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate, public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo, CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration) { LocalDate dateOfRegistration) {
this(constitution, incorpNo, incorpValidityTillDate, mainBusinessLine, remarks, authorizedSignatory, additionalInfo,
dateOfRegistration, null);
}
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
this.constitution = constitution; this.constitution = constitution;
this.incorpNumber = incorpNo; this.incorpNumber = incorpNo;
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
this.mainBusinessLine = mainBusinessLine; this.mainBusinessLine = mainBusinessLine;
this.remarks = remarks; this.remarks = remarks;
this.authorizedSignatory = authorizedSignatory; this.authorizedSignatory = authorizedSignatory;
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
this.additionalInfo = additionalInfo; this.additionalInfo = additionalInfo;
this.dateOfRegistration = dateOfRegistration; this.dateOfRegistration = dateOfRegistration;
} }
@@ -294,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "alternate_dip_timestamp") @Column(name = "alternate_dip_timestamp")
private String alternateDipTimestamp; private String alternateDipTimestamp;
/**
* The DIP/MNRL status the number actually returned — CLEAN, SUSPECTED, DISCONNECTED or
* SERVICE_UNAVAILABLE. dipVerified alone cannot tell "checked and clean" apart from "the DoT
* service was down", which previously stored both as verified.
*/
@Column(name = "dip_status", length = 40)
private String dipStatus;
@Column(name = "alternate_dip_status", length = 40)
private String alternateDipStatus;
@Column(name = "name_verified") @Column(name = "name_verified")
private Boolean nameVerified; private Boolean nameVerified;
@@ -25,11 +25,15 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.UniqueConstraint; import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate; import java.time.LocalDate;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map; import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
@@ -61,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@JoinColumn(name = "client_id", nullable = false) @JoinColumn(name = "client_id", nullable = false)
private Client client; private Client client;
/**
* Scratch record of what the in-flight {@link #update} replaced, read back via
* {@link #getFieldsOverwrittenByLastUpdate()}. Never persisted and never read outside the
* transaction that called update(), which clears it first.
*/
@Transient
private final transient Set<String> fieldsOverwrittenByLastUpdate = new LinkedHashSet<>();
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */ /** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
@Column(name = "kyc_status", nullable = false) @Column(name = "kyc_status", nullable = false)
private Integer kycStatus; private Integer kycStatus;
@@ -440,11 +452,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public Map<String, Object> update(final JsonCommand command) { public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(); final Map<String, Object> actualChanges = new LinkedHashMap<>();
this.fieldsOverwrittenByLastUpdate.clear();
final String panNumberParam = "panNumber"; final String panNumberParam = "panNumber";
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) { if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
final String newValue = command.stringValueOfParameterNamed(panNumberParam); final String newValue = command.stringValueOfParameterNamed(panNumberParam);
actualChanges.put(panNumberParam, newValue); actualChanges.put(panNumberParam, newValue);
recordOverwrite(panNumberParam, this.panNumber);
this.panNumber = StringUtils.defaultIfEmpty(newValue, null); this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
} }
@@ -452,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) { if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
final String newValue = command.stringValueOfParameterNamed(aadhaarParam); final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
actualChanges.put(aadhaarParam, newValue); actualChanges.put(aadhaarParam, newValue);
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null); this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
} }
@@ -459,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) { if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
final String newValue = command.stringValueOfParameterNamed(gstinParam); final String newValue = command.stringValueOfParameterNamed(gstinParam);
actualChanges.put(gstinParam, newValue); actualChanges.put(gstinParam, newValue);
recordOverwrite(gstinParam, this.gstinNumber);
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null); this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
} }
@@ -466,9 +482,12 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) { if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
final Integer newValue = command.integerValueOfParameterNamed(riskParam); final Integer newValue = command.integerValueOfParameterNamed(riskParam);
actualChanges.put(riskParam, newValue); actualChanges.put(riskParam, newValue);
recordOverwrite(riskParam, this.riskCategory);
this.riskCategory = newValue; this.riskCategory = newValue;
} }
// remarks is the audit justification itself, so a change to it is deliberately not recorded
// as an overwrite — requiring remarks in order to write remarks would be circular.
final String remarksParam = "remarks"; final String remarksParam = "remarks";
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) { if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
final String newValue = command.stringValueOfParameterNamed(remarksParam); final String newValue = command.stringValueOfParameterNamed(remarksParam);
@@ -551,6 +570,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(param, current)) { if (command.isChangeInStringParameterNamed(param, current)) {
final String newValue = command.stringValueOfParameterNamed(param); final String newValue = command.stringValueOfParameterNamed(param);
changes.put(param, newValue); changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(StringUtils.defaultIfEmpty(newValue, null)); setter.accept(StringUtils.defaultIfEmpty(newValue, null));
} }
} }
@@ -560,6 +580,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInLocalDateParameterNamed(param, current)) { if (command.isChangeInLocalDateParameterNamed(param, current)) {
final LocalDate newValue = command.localDateValueOfParameterNamed(param); final LocalDate newValue = command.localDateValueOfParameterNamed(param);
changes.put(param, newValue); changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(newValue); setter.accept(newValue);
} }
} }
@@ -570,6 +591,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param); final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) { if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
changes.put(param, newValue); changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(newValue); setter.accept(newValue);
} }
} }
@@ -580,10 +602,40 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInBigDecimalParameterNamed(param, current)) { if (command.isChangeInBigDecimalParameterNamed(param, current)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param); final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
changes.put(param, newValue); changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(newValue); setter.accept(newValue);
} }
} }
/**
* Notes that {@code param} replaced a value the record already held. A field going from empty to
* populated is the record being completed rather than altered, so it is deliberately not
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
*/
private void recordOverwrite(final String param, final Object currentValue) {
final boolean wasPopulated = currentValue instanceof String text
? StringUtils.isNotBlank(text)
: currentValue != null;
if (wasPopulated) {
this.fieldsOverwrittenByLastUpdate.add(param);
}
}
/**
* Parameters whose already-populated value the most recent {@link #update} replaced.
*
* <p>Distinguishes altering a record from finishing one: a non-individual KYC auto-verifies as
* soon as PAN and GSTIN pass, which happens part-way through the create wizard, and the officer
* still has to save the KYC document details afterwards. Those fields are empty at that point,
* so filling them in leaves this set empty and no audit justification is demanded — while
* changing a document number that was already on the verified record does populate it.
*
* <p>Reset at the start of every {@link #update} and never persisted: it describes one call.
*/
public Set<String> getFieldsOverwrittenByLastUpdate() {
return Collections.unmodifiableSet(this.fieldsOverwrittenByLastUpdate);
}
/** Called when documents are submitted and 3rd-party verification is initiated. */ /** Called when documents are submitted and 3rd-party verification is initiated. */
public void markInProgress() { public void markInProgress() {
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue(); this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder(toBuilder = true)
public final class BatchRunData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final String jobType;
private final String branchScope;
private final LocalDate jobDate;
private final Long agentFrom;
private final Long agentTo;
private final Long partyFrom;
private final Long partyTo;
private final String status;
private final Integer totalRows;
private final BigDecimal totalAmount;
private final LocalDate generatedOnDate;
private final LocalDate postedOnDate;
private final Collection<BatchRunRowData> rows;
}
@@ -0,0 +1,41 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class BatchRunRowData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final Long agentId;
private final String agentCode;
private final BigDecimal amount;
}
@@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One Calculate-then-Post batch run - the deliberate two-step control from the legacy Interest Part
* menu (Interest Calculation -> Interest Posting, OD Interest Calculation -> OD Interest Posting,
* Commission Calculation -> Commission Posting): a run is generated (rows computed and saved as a
* preview, nothing else changes), then posted (a separate, explicit step that applies exactly the
* rows that were previewed - never a fresh recalculation, so what gets applied is always what was
* seen).
*/
@Getter
@Entity
@Table(name = "m_dds_batch_run")
public class BatchRun extends AbstractPersistableCustom<Long> {
@Column(name = "job_type", length = 40, nullable = false)
private String jobType;
@Column(name = "branch_scope", length = 20, nullable = false)
private String branchScope;
@Column(name = "job_date", nullable = false)
private LocalDate jobDate;
@Column(name = "agent_from", nullable = false)
private Long agentFrom;
@Column(name = "agent_to", nullable = false)
private Long agentTo;
@Column(name = "party_from")
private Long partyFrom;
@Column(name = "party_to")
private Long partyTo;
@Column(name = "status", length = 20, nullable = false)
private String status;
@Column(name = "total_rows", nullable = false)
private Integer totalRows;
@Column(name = "total_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal totalAmount;
@Column(name = "generated_on_date", nullable = false)
private LocalDate generatedOnDate;
@Column(name = "posted_on_date")
private LocalDate postedOnDate;
@OneToMany(mappedBy = "batchRun", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<BatchRunRow> rows = new ArrayList<>();
protected BatchRun() {
//
}
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate) {
this.jobType = jobType;
this.branchScope = branchScope;
this.jobDate = jobDate;
this.agentFrom = agentFrom;
this.agentTo = agentTo;
this.partyFrom = partyFrom;
this.partyTo = partyTo;
this.status = "CALCULATED";
this.totalRows = 0;
this.totalAmount = BigDecimal.ZERO;
this.generatedOnDate = generatedOnDate;
}
public void addRow(final BatchRunRow row) {
row.setBatchRun(this);
this.rows.add(row);
this.totalRows = this.rows.size();
this.totalAmount = this.totalAmount.add(row.getAmount());
}
public boolean isPosted() {
return "POSTED".equals(this.status);
}
public void markPosted(final LocalDate postedOnDate) {
this.status = "POSTED";
this.postedOnDate = postedOnDate;
}
}
@@ -0,0 +1,65 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One previewed/posted line of a {@link BatchRun}: for an interest job, one Party's accrued interest;
* for a commission job, one Agent's commission on their collected volume (partyId is null there).
*/
@Getter
@Entity
@Table(name = "m_dds_batch_run_row")
public class BatchRunRow extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "batch_run_id", nullable = false)
private BatchRun batchRun;
@Column(name = "party_id")
private Long partyId;
@Column(name = "agent_id", nullable = false)
private Long agentId;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
protected BatchRunRow() {
//
}
public BatchRunRow(final Long partyId, final Long agentId, final BigDecimal amount) {
this.partyId = partyId;
this.agentId = agentId;
this.amount = amount;
}
void setBatchRun(final BatchRun batchRun) {
this.batchRun = batchRun;
}
}
@@ -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.dds.batch.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when Post is attempted on a batch run that has already been posted - a run applies once,
* matching the calculate-then-post safety control (never re-post the same preview).
*/
public class BatchRunAlreadyPostedException extends AbstractPlatformDomainRuleException {
public BatchRunAlreadyPostedException(final Long batchRunId) {
super("error.msg.ddsbatchrun.already.posted", "DDS batch run " + batchRunId + " has already been posted", batchRunId);
}
}
@@ -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.dds.batch.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class BatchRunNotFoundException extends AbstractPlatformResourceNotFoundException {
public BatchRunNotFoundException(final Long id) {
super("error.msg.ddsbatchrun.id.invalid", "DDS batch run with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,107 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Builder;
import lombok.Getter;
/**
* Immutable data object representing a DDS Party (customer scheme account), including its nominees.
* Built via the Lombok builder rather than a long positional constructor - the field count here makes
* positional args unreadable and error-prone to keep in sync.
*/
@Getter
@Builder(toBuilder = true)
public final class PartyData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String clientName;
private final Long agentId;
private final String agentCode;
private final String agentName;
private final Long savingsAccountId;
private final String subType;
private final String partyCode;
private final String lastAccount;
private final Boolean nominal;
private final String aadharNo;
private final String partyName;
private final String partyName1;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String gender;
private final String panCardNo;
private final String telephone;
private final String mobileNoSms;
private final String operation;
private final LocalDate birthDate;
private final String occupation;
private final String risk;
private final String ckycId;
private final String adultMinor;
private final LocalDate minorDate;
private final Long guardianClientId;
private final String minAccountOpt;
private final String relation;
private final LocalDate openingDate;
private final Integer month;
private final LocalDate maturityDate;
private final LocalDate reopenDate;
private final BigDecimal depositAmount;
private final BigDecimal dailyDepositAmount;
private final Integer days;
private final String ledgerNo;
private final BigDecimal maturityAmount;
private final BigDecimal interestRate;
private final String withdrawType;
private final Boolean od1Flag;
private final Boolean od2Flag;
private final Boolean lien;
private final LocalDate payDate;
private final BigDecimal paymentAmount;
private final BigDecimal currentBalance;
private final BigDecimal openingBalance;
private final Boolean isSmsActive;
private final Boolean isEmailActive;
private final BigDecimal interestCalc;
private final LocalDate interestDate;
private final String spDescription;
private final String freezeCode;
private final String advCode;
private final String creditToBranch;
private final String creditToAccountType;
private final String creditToAccount;
private final Boolean nomineeApplicable;
private final String nomineeSplitMode;
private final String status;
private final LocalDate closeDate;
private final Collection<PartyNomineeData> nominees;
}
@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Getter;
@Getter
public final class PartyNomineeData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final LocalDate nominationDate;
private final String name;
private final String mobileNo;
private final LocalDate birthDate;
private final Boolean minor;
private final String relation;
private final String gender;
private final String guardianName;
public static PartyNomineeData instance(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
return new PartyNomineeData(id, nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName);
}
private PartyNomineeData(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
this.id = id;
this.nominationDate = nominationDate;
this.name = name;
this.mobileNo = mobileNo;
this.birthDate = birthDate;
this.minor = minor;
this.relation = relation;
this.gender = gender;
this.guardianName = guardianName;
}
}
@@ -0,0 +1,569 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* A DDS Party - the customer's daily-deposit scheme account, linked to the Agent who collects for it.
* Carries the scheme's KYC, terms and two-stage overdue flags, plus a nominee list.
*
* The underlying RD (recurring deposit) account this scheme conceptually reuses is not wired up yet -
* {@code savingsAccountId} is a nullable placeholder for that future linkage (see the DDS architecture
* decision to reuse the RD engine for the deposit half). Until then the scheme/balance fields below
* are this record's own, not mirrored from any m_savings_account row.
*/
@Getter
@Entity
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
public class Party extends AbstractPersistableCustom<Long> {
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */
private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_CLOSED = "CLOSED";
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "savings_account_id")
private Long savingsAccountId;
@Column(name = "sub_type", length = 60, nullable = false)
private String subType;
@Column(name = "party_code", length = 20, nullable = false)
private String partyCode;
@Column(name = "last_account", length = 50)
private String lastAccount;
@Column(name = "nominal", nullable = false)
private boolean nominal;
@Column(name = "aadhar_no", length = 20)
private String aadharNo;
@Column(name = "party_name", length = 100, nullable = false)
private String partyName;
@Column(name = "party_name1", length = 100)
private String partyName1;
@Column(name = "address1", length = 200, nullable = false)
private String address1;
@Column(name = "address2", length = 200)
private String address2;
@Column(name = "address3", length = 200)
private String address3;
@Column(name = "city", length = 100, nullable = false)
private String city;
@Column(name = "pincode", length = 20, nullable = false)
private String pincode;
@Column(name = "gender", length = 20)
private String gender;
@Column(name = "pan_card_no", length = 20)
private String panCardNo;
@Column(name = "telephone", length = 20)
private String telephone;
@Column(name = "mobile_no_sms", length = 20, nullable = false)
private String mobileNoSms;
@Column(name = "operation", length = 20, nullable = false)
private String operation;
@Column(name = "birth_date")
private LocalDate birthDate;
@Column(name = "occupation", length = 40)
private String occupation;
@Column(name = "risk", length = 20)
private String risk;
@Column(name = "ckyc_id", length = 40)
private String ckycId;
@Column(name = "adult_minor", length = 20, nullable = false)
private String adultMinor;
@Column(name = "minor_date")
private LocalDate minorDate;
@Column(name = "guardian_client_id")
private Long guardianClientId;
@Column(name = "min_account_opt", length = 40)
private String minAccountOpt;
@Column(name = "relation", length = 40)
private String relation;
@Column(name = "opening_date", nullable = false)
private LocalDate openingDate;
@Column(name = "months")
private Integer month;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "reopen_date")
private LocalDate reopenDate;
@Column(name = "deposit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal depositAmount;
@Column(name = "daily_deposit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal dailyDepositAmount;
@Column(name = "days", nullable = false)
private Integer days;
@Column(name = "ledger_no", length = 40)
private String ledgerNo;
@Column(name = "maturity_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal maturityAmount;
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal interestRate;
@Column(name = "withdraw_type", length = 20)
private String withdrawType;
@Column(name = "od1_flag", nullable = false)
private boolean od1Flag;
@Column(name = "od2_flag", nullable = false)
private boolean od2Flag;
@Column(name = "lien", nullable = false)
private boolean lien;
@Column(name = "pay_date")
private LocalDate payDate;
@Column(name = "payment_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal paymentAmount;
@Column(name = "current_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal currentBalance;
@Column(name = "opening_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal openingBalance;
@Column(name = "is_sms_active", nullable = false)
private boolean isSmsActive;
@Column(name = "is_email_active", nullable = false)
private boolean isEmailActive;
@Column(name = "interest_calc", scale = 6, precision = 19, nullable = false)
private BigDecimal interestCalc;
@Column(name = "interest_date")
private LocalDate interestDate;
@Column(name = "sp_description", length = 500)
private String spDescription;
@Column(name = "freeze_code", length = 20, nullable = false)
private String freezeCode;
@Column(name = "adv_code", length = 40, nullable = false)
private String advCode;
@Column(name = "credit_to_branch", length = 100)
private String creditToBranch;
@Column(name = "credit_to_account_type", length = 100)
private String creditToAccountType;
@Column(name = "credit_to_account", length = 50)
private String creditToAccount;
@Column(name = "nominee_applicable", nullable = false)
private boolean nomineeApplicable;
@Column(name = "nominee_split_mode", length = 20)
private String nomineeSplitMode;
@Column(name = "status", length = 20, nullable = false)
private String status;
@Column(name = "close_date")
private LocalDate closeDate;
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<PartyNominee> nominees = new ArrayList<>();
protected Party() {
//
}
private Party(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
this.client = client;
this.agent = agent;
this.partyCode = partyCode;
this.subType = command.stringValueOfParameterNamed("subType");
this.lastAccount = command.stringValueOfParameterNamedAllowingNull("lastAccount");
this.nominal = command.booleanPrimitiveValueOfParameterNamed("nominal");
this.aadharNo = command.stringValueOfParameterNamedAllowingNull("aadharNo");
this.partyName = command.stringValueOfParameterNamed("partyName");
this.partyName1 = command.stringValueOfParameterNamedAllowingNull("partyName1");
this.address1 = command.stringValueOfParameterNamed("address1");
this.address2 = command.stringValueOfParameterNamedAllowingNull("address2");
this.address3 = command.stringValueOfParameterNamedAllowingNull("address3");
this.city = command.stringValueOfParameterNamed("city");
this.pincode = command.stringValueOfParameterNamed("pincode");
this.gender = command.stringValueOfParameterNamedAllowingNull("gender");
this.panCardNo = command.stringValueOfParameterNamedAllowingNull("panCardNo");
this.telephone = command.stringValueOfParameterNamedAllowingNull("telephone");
this.mobileNoSms = command.stringValueOfParameterNamed("mobileNoSms");
this.operation = command.stringValueOfParameterNamed("operation");
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
this.occupation = command.stringValueOfParameterNamedAllowingNull("occupation");
this.risk = command.stringValueOfParameterNamedAllowingNull("risk");
this.ckycId = command.stringValueOfParameterNamedAllowingNull("ckycId");
this.adultMinor = command.stringValueOfParameterNamed("adultMinor");
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
this.guardianClientId = command.longValueOfParameterNamed("guardianClientId");
this.minAccountOpt = command.stringValueOfParameterNamedAllowingNull("minAccountOpt");
this.relation = command.stringValueOfParameterNamedAllowingNull("relation");
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
this.month = command.integerValueOfParameterNamedDefaultToNullIfZero("month");
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
this.depositAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("depositAmount"));
this.dailyDepositAmount = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
this.days = command.integerValueOfParameterNamed("days");
this.ledgerNo = command.stringValueOfParameterNamedAllowingNull("ledgerNo");
this.maturityAmount = BigDecimal.ZERO;
this.interestRate = command.parameterExists("interestRate")
? command.bigDecimalValueOfParameterNamed("interestRate")
: DEFAULT_INTEREST_RATE;
this.withdrawType = command.stringValueOfParameterNamedAllowingNull("withdrawType");
this.od1Flag = false;
this.od2Flag = false;
this.lien = command.booleanPrimitiveValueOfParameterNamed("lien");
this.payDate = command.localDateValueOfParameterNamed("payDate");
this.paymentAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("paymentAmount"));
this.openingBalance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("openingBalance"));
this.currentBalance = this.openingBalance;
this.isSmsActive = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
this.isEmailActive = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
this.interestCalc = BigDecimal.ZERO;
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
this.spDescription = command.stringValueOfParameterNamedAllowingNull("spDescription");
this.freezeCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("freezeCode"), "Normal");
this.advCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("advCode"), "None");
this.creditToBranch = command.stringValueOfParameterNamedAllowingNull("creditToBranch");
this.creditToAccountType = command.stringValueOfParameterNamedAllowingNull("creditToAccountType");
this.creditToAccount = command.stringValueOfParameterNamedAllowingNull("creditToAccount");
this.nomineeApplicable = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
this.nomineeSplitMode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("nomineeSplitMode"),
"Percentage");
this.status = STATUS_ACTIVE;
}
public static Party fromJson(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
return new Party(client, agent, partyCode, command);
}
private static BigDecimal orZero(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
public void addNominee(final PartyNominee nominee) {
nominee.setParty(this);
this.nominees.add(nominee);
}
public void resetNominees() {
this.nominees.clear();
}
public void changeClient(final Client newClient) {
this.client = newClient;
}
public void changeAgent(final Agent newAgent) {
this.agent = newAgent;
}
/**
* Links this Party to an existing real Savings Account, resolved by account number in the write
* service. There is no unlink - once a Party's DDS scheme is tied to a real account, it stays
* tied.
*/
public void linkSavingsAccount(final Long savingsAccountId) {
this.savingsAccountId = savingsAccountId;
}
public BigDecimal credit(final BigDecimal amount) {
this.currentBalance = this.currentBalance.add(amount);
return this.currentBalance;
}
public BigDecimal debit(final BigDecimal amount) {
this.currentBalance = this.currentBalance.subtract(amount);
return this.currentBalance;
}
public boolean isClosed() {
return STATUS_CLOSED.equals(this.status);
}
/**
* Closes this Party's account as of the given date. The balance must already be zero - a Party
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
* still owed to it.
*/
public void close(final LocalDate closeDate) {
this.status = STATUS_CLOSED;
this.closeDate = closeDate;
}
/**
* Stamps the date interest (regular or OD) was last calculated up to, so the next batch run
* accrues only the days since. Set on POST, not on calculate/preview.
*/
public void markInterestCalculatedThrough(final LocalDate date) {
this.interestDate = date;
}
public Long clientId() {
return this.client.getId();
}
public Long agentId() {
return this.agent.getId();
}
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(40);
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
}
if (command.isChangeInLongParameterNamed("agentId", this.agent.getId())) {
actualChanges.put("agentId", command.longValueOfParameterNamed("agentId"));
}
stringChange(command, actualChanges, "subType", this.subType, v -> this.subType = v);
stringChange(command, actualChanges, "lastAccount", this.lastAccount, v -> this.lastAccount = v);
if (command.isChangeInBooleanParameterNamed("nominal", this.nominal)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nominal");
actualChanges.put("nominal", newValue);
this.nominal = newValue;
}
stringChange(command, actualChanges, "aadharNo", this.aadharNo, v -> this.aadharNo = v);
stringChange(command, actualChanges, "partyName", this.partyName, v -> this.partyName = v);
stringChange(command, actualChanges, "partyName1", this.partyName1, v -> this.partyName1 = v);
stringChange(command, actualChanges, "address1", this.address1, v -> this.address1 = v);
stringChange(command, actualChanges, "address2", this.address2, v -> this.address2 = v);
stringChange(command, actualChanges, "address3", this.address3, v -> this.address3 = v);
stringChange(command, actualChanges, "city", this.city, v -> this.city = v);
stringChange(command, actualChanges, "pincode", this.pincode, v -> this.pincode = v);
stringChange(command, actualChanges, "gender", this.gender, v -> this.gender = v);
stringChange(command, actualChanges, "panCardNo", this.panCardNo, v -> this.panCardNo = v);
stringChange(command, actualChanges, "telephone", this.telephone, v -> this.telephone = v);
stringChange(command, actualChanges, "mobileNoSms", this.mobileNoSms, v -> this.mobileNoSms = v);
stringChange(command, actualChanges, "operation", this.operation, v -> this.operation = v);
if (command.isChangeInDateParameterNamed("birthDate", this.birthDate)) {
actualChanges.put("birthDate", command.stringValueOfParameterNamed("birthDate"));
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
}
stringChange(command, actualChanges, "occupation", this.occupation, v -> this.occupation = v);
stringChange(command, actualChanges, "risk", this.risk, v -> this.risk = v);
stringChange(command, actualChanges, "ckycId", this.ckycId, v -> this.ckycId = v);
stringChange(command, actualChanges, "adultMinor", this.adultMinor, v -> this.adultMinor = v);
if (command.isChangeInDateParameterNamed("minorDate", this.minorDate)) {
actualChanges.put("minorDate", command.stringValueOfParameterNamed("minorDate"));
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
}
if (command.isChangeInLongParameterNamed("guardianClientId", this.guardianClientId)) {
final Long newValue = command.longValueOfParameterNamed("guardianClientId");
actualChanges.put("guardianClientId", newValue);
this.guardianClientId = newValue;
}
stringChange(command, actualChanges, "minAccountOpt", this.minAccountOpt, v -> this.minAccountOpt = v);
stringChange(command, actualChanges, "relation", this.relation, v -> this.relation = v);
if (command.isChangeInDateParameterNamed("openingDate", this.openingDate)) {
actualChanges.put("openingDate", command.stringValueOfParameterNamed("openingDate"));
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
}
if (command.isChangeInIntegerParameterNamed("month", this.month)) {
final Integer newValue = command.integerValueOfParameterNamed("month");
actualChanges.put("month", newValue);
this.month = newValue;
}
if (command.isChangeInDateParameterNamed("maturityDate", this.maturityDate)) {
actualChanges.put("maturityDate", command.stringValueOfParameterNamed("maturityDate"));
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
}
if (command.isChangeInDateParameterNamed("reopenDate", this.reopenDate)) {
actualChanges.put("reopenDate", command.stringValueOfParameterNamed("reopenDate"));
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
}
if (command.isChangeInBigDecimalParameterNamed("depositAmount", this.depositAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("depositAmount");
actualChanges.put("depositAmount", newValue);
this.depositAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("dailyDepositAmount", this.dailyDepositAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
actualChanges.put("dailyDepositAmount", newValue);
this.dailyDepositAmount = newValue;
}
if (command.isChangeInIntegerParameterNamed("days", this.days)) {
final Integer newValue = command.integerValueOfParameterNamed("days");
actualChanges.put("days", newValue);
this.days = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
actualChanges.put("interestRate", newValue);
this.interestRate = newValue;
}
stringChange(command, actualChanges, "ledgerNo", this.ledgerNo, v -> this.ledgerNo = v);
stringChange(command, actualChanges, "withdrawType", this.withdrawType, v -> this.withdrawType = v);
if (command.isChangeInBooleanParameterNamed("lien", this.lien)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("lien");
actualChanges.put("lien", newValue);
this.lien = newValue;
}
if (command.isChangeInDateParameterNamed("payDate", this.payDate)) {
actualChanges.put("payDate", command.stringValueOfParameterNamed("payDate"));
this.payDate = command.localDateValueOfParameterNamed("payDate");
}
if (command.isChangeInBigDecimalParameterNamed("paymentAmount", this.paymentAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("paymentAmount");
actualChanges.put("paymentAmount", newValue);
this.paymentAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("openingBalance", this.openingBalance)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("openingBalance");
actualChanges.put("openingBalance", newValue);
this.openingBalance = newValue;
}
if (command.isChangeInBooleanParameterNamed("isSmsActive", this.isSmsActive)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
actualChanges.put("isSmsActive", newValue);
this.isSmsActive = newValue;
}
if (command.isChangeInBooleanParameterNamed("isEmailActive", this.isEmailActive)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
actualChanges.put("isEmailActive", newValue);
this.isEmailActive = newValue;
}
if (command.isChangeInDateParameterNamed("interestDate", this.interestDate)) {
actualChanges.put("interestDate", command.stringValueOfParameterNamed("interestDate"));
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
}
stringChange(command, actualChanges, "spDescription", this.spDescription, v -> this.spDescription = v);
stringChange(command, actualChanges, "freezeCode", this.freezeCode, v -> this.freezeCode = v);
stringChange(command, actualChanges, "advCode", this.advCode, v -> this.advCode = v);
stringChange(command, actualChanges, "creditToBranch", this.creditToBranch, v -> this.creditToBranch = v);
stringChange(command, actualChanges, "creditToAccountType", this.creditToAccountType, v -> this.creditToAccountType = v);
stringChange(command, actualChanges, "creditToAccount", this.creditToAccount, v -> this.creditToAccount = v);
if (command.isChangeInBooleanParameterNamed("nomineeApplicable", this.nomineeApplicable)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
actualChanges.put("nomineeApplicable", newValue);
this.nomineeApplicable = newValue;
}
stringChange(command, actualChanges, "nomineeSplitMode", this.nomineeSplitMode, v -> this.nomineeSplitMode = v);
return actualChanges;
}
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
final String existingValue, final Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
final String newValue = command.stringValueOfParameterNamed(paramName);
actualChanges.put(paramName, newValue);
setter.accept(newValue);
}
}
}
@@ -0,0 +1,88 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.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 lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A single nominee recorded against a {@link Party}. Maker-checker on nominee edits is enforced at
* the command layer (the whole Party update goes through the standard maker-checker command flow),
* not on this child row individually.
*/
@Getter
@Entity
@Table(name = "m_dds_party_nominee")
public class PartyNominee extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "party_id", nullable = false)
private Party party;
@Column(name = "nomination_date", nullable = false)
private LocalDate nominationDate;
@Column(name = "name", length = 100, nullable = false)
private String name;
@Column(name = "mobile_no", length = 20)
private String mobileNo;
@Column(name = "birth_date")
private LocalDate birthDate;
@Column(name = "minor", nullable = false)
private boolean minor;
@Column(name = "relation", length = 40, nullable = false)
private String relation;
@Column(name = "gender", length = 20, nullable = false)
private String gender;
@Column(name = "guardian_name", length = 100)
private String guardianName;
protected PartyNominee() {
//
}
public PartyNominee(final LocalDate nominationDate, final String name, final String mobileNo, final LocalDate birthDate,
final boolean minor, final String relation, final String gender, final String guardianName) {
this.nominationDate = nominationDate;
this.name = name;
this.mobileNo = StringUtils.defaultIfEmpty(mobileNo, null);
this.birthDate = birthDate;
this.minor = minor;
this.relation = relation;
this.gender = gender;
this.guardianName = StringUtils.defaultIfEmpty(guardianName, null);
}
void setParty(final Party party) {
this.party = party;
}
}
@@ -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.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a Close Party command, or a Party Tran collection, targets a Party that is already
* closed.
*/
public class PartyAlreadyClosedException extends AbstractPlatformDomainRuleException {
public PartyAlreadyClosedException(final String partyCode) {
super("error.msg.party.already.closed", "Party " + partyCode + " is already closed", partyCode);
}
}
@@ -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.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a Close Party command is attempted while the Party's balance is still non-zero - the
* remaining balance must be paid out (a Withdraw Deposite to zero) before the account can be closed.
*/
public class PartyHasBalanceException extends AbstractPlatformDomainRuleException {
public PartyHasBalanceException(final String partyCode) {
super("error.msg.party.has.balance", "Party " + partyCode + " still has a non-zero balance and cannot be closed", partyCode);
}
}
@@ -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.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* A {@link RuntimeException} thrown when a DDS party resource is not found.
*/
public class PartyNotFoundException extends AbstractPlatformResourceNotFoundException {
public PartyNotFoundException(final Long id) {
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id);
}
public PartyNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id, e);
}
public PartyNotFoundException(final String partyCode) {
super("error.msg.party.code.invalid", "Party with code " + partyCode + " does not exist", partyCode);
}
}
@@ -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.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when the savingsAccountNo given for a Party belongs to a different client than the Party's
* own clientId - a Party can only be linked to its own client's savings account.
*/
public class PartySavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
public PartySavingsAccountClientMismatchException(final String savingsAccountNo) {
super("error.msg.party.savings.account.client.mismatch",
"Savings account " + savingsAccountNo + " does not belong to this party's client", savingsAccountNo);
}
}
@@ -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.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
/**
* Thrown when a Party's savingsAccountNo does not match any non-closed Savings Account.
*/
public class PartySavingsAccountNotFoundException extends AbstractPlatformResourceNotFoundException {
public PartySavingsAccountNotFoundException(final String savingsAccountNo) {
super("error.msg.party.savings.account.not.found", "No open savings account found with account number " + savingsAccountNo,
savingsAccountNo);
}
}
@@ -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.dds.report.data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
/**
* A generic tabular result shape shared by every DDS report - one column list plus one row list, both
* keyed the same way, so a single report viewer screen can render any of them without a bespoke
* result type per report.
*/
@Getter
@Builder
public final class DdsReportData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final String reportKey;
private final boolean available;
private final String message;
private final List<String> columns;
private final List<Map<String, Object>> rows;
}
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class PartyTransactionData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final Long agentId;
private final String agentCode;
private final LocalDate transactionDate;
private final String transactionType;
private final String direction;
private final String transactionMode;
private final String accountType;
private final String accountNo;
private final BigDecimal amount;
private final String chequeNo;
private final String tokenNo;
private final String description;
private final BigDecimal balanceAfterTransaction;
}
@@ -0,0 +1,110 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.dds.party.domain.Party;
/**
* A single posted entry on the DDS ledger: a Party's day's collection ("Party Tran", direction
* CREDIT), one leg of a "Withdraw Deposite" transfer between two parties (the creditor leg CREDIT,
* the debtor leg DEBIT), or an Agent's settlement of collected cash to its branch ("Agent Tran",
* direction DEBIT - no {@link Party} involved, so {@code party} is null for this type).
* {@code balanceAfterTransaction} holds the Party's balance for Party-linked rows, or the Agent's
* balance for an Agent Tran row. Immutable once posted - like any transaction, it is reversed with
* an opposing entry, not edited, so there is deliberately no update API for this entity.
*/
@Getter
@Entity
@Table(name = "m_dds_party_transaction")
public class PartyTransaction extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "party_id")
private Party party;
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate;
@Column(name = "transaction_type", length = 30, nullable = false)
private String transactionType;
@Column(name = "direction", length = 10, nullable = false)
private String direction;
@Column(name = "transaction_mode", length = 20)
private String transactionMode;
@Column(name = "account_type", length = 100)
private String accountType;
@Column(name = "account_no", length = 50)
private String accountNo;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
@Column(name = "cheque_no", length = 40)
private String chequeNo;
@Column(name = "token_no", length = 40)
private String tokenNo;
@Column(name = "description", length = 500)
private String description;
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
private BigDecimal balanceAfterTransaction;
protected PartyTransaction() {
//
}
public PartyTransaction(final Party party, final Agent agent, final LocalDate transactionDate, final String transactionType,
final String direction, final String transactionMode, final String accountType, final String accountNo,
final BigDecimal amount, final String chequeNo, final String tokenNo, final String description,
final BigDecimal balanceAfterTransaction) {
this.party = party;
this.agent = agent;
this.transactionDate = transactionDate;
this.transactionType = transactionType;
this.direction = direction;
this.transactionMode = transactionMode;
this.accountType = accountType;
this.accountNo = accountNo;
this.amount = amount;
this.chequeNo = chequeNo;
this.tokenNo = tokenNo;
this.description = description;
this.balanceAfterTransaction = balanceAfterTransaction;
}
}
@@ -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.dds.transaction.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when an Agent Tran settlement amount exceeds the Agent's current (collected but not yet
* settled) balance.
*/
public class InsufficientAgentBalanceException extends AbstractPlatformDomainRuleException {
public InsufficientAgentBalanceException(final String agentCode) {
super("error.msg.agent.insufficient.balance",
"Agent " + agentCode + " does not have sufficient collected balance for this settlement", agentCode);
}
}
@@ -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.dds.transaction.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a Withdraw Deposite transfer would take the debtor Party's balance below zero.
*/
public class InsufficientPartyBalanceException extends AbstractPlatformDomainRuleException {
public InsufficientPartyBalanceException(final String partyCode) {
super("error.msg.party.insufficient.balance", "Party " + partyCode + " does not have sufficient balance for this transfer",
partyCode);
}
}
@@ -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.dds.transaction.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* A {@link RuntimeException} thrown when a DDS party transaction resource is not found.
*/
public class PartyTransactionNotFoundException extends AbstractPlatformResourceNotFoundException {
public PartyTransactionNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.partytransaction.id.invalid", "Party transaction with identifier " + id + " does not exist", id, e);
}
}
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
@Column(name = "looked_up_on", nullable = false) @Column(name = "looked_up_on", nullable = false)
private OffsetDateTime lookedUpOn; private OffsetDateTime lookedUpOn;
// ── Officer decision on a non-clean result ────────────────────────────────
// A DIP hit is a fraud-risk signal rather than a legal bar, so onboarding may continue — but
// the officer has to say why in writing, and that reasoning is kept with the lookup.
/** PROCEEDED | REJECTED. Null while no decision is required or none has been taken. */
@Column(name = "override_decision", length = 20)
private String overrideDecision;
@Column(name = "override_reason")
private String overrideReason;
@Column(name = "decided_by")
private Long decidedBy;
@Column(name = "decided_on")
private OffsetDateTime decidedOn;
public static final String STATUS_CLEAN = "CLEAN";
public static final String STATUS_ERROR = "ERROR";
public static final String STATUS_SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE";
public static final String DECISION_PROCEEDED = "PROCEEDED";
public static final String DECISION_REJECTED = "REJECTED";
protected MobileLookupLog() {} protected MobileLookupLog() {}
public MobileLookupLog(final String mobileNumber, final String lookupStatus, public MobileLookupLog(final String mobileNumber, final String lookupStatus,
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
this.clientId = clientId; this.clientId = clientId;
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant(); this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
} }
public void recordDecision(final String decision, final String reason, final Long userId) {
this.overrideDecision = decision;
this.overrideReason = reason;
this.decidedBy = userId;
this.decidedOn = DateUtils.getOffsetDateTimeOfTenant();
}
/** True when the lookup produced something the officer must consciously decide about. */
public boolean requiresDecision() {
return this.suspected || this.disconnected;
}
/**
* A number may be used only when it came back clean, or when an officer explicitly chose to
* proceed. An unreachable DoT service is NOT a pass — it means nothing was checked.
*/
public boolean isUsable() {
if (STATUS_CLEAN.equals(this.lookupStatus)) {
return true;
}
return DECISION_PROCEEDED.equals(this.overrideDecision);
}
} }
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.mobilelookup.domain; package org.apache.fineract.portfolio.mobilelookup.domain;
import java.util.List; import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
@@ -35,4 +37,29 @@ public interface MobileLookupLogRepository extends JpaRepository<MobileLookupLog
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC") @Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findByLookupStatus(@Param("status") String status); List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
/** Non-clean lookups still waiting for an officer's decision — the DIP review queue. */
@Query("SELECT l FROM MobileLookupLog l WHERE (l.suspected = true OR l.disconnected = true) "
+ "AND l.overrideDecision IS NULL ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findPendingDecisions();
/**
* Most recent lookup for a number, used by the server-side gate to confirm the browser's
* claim that a number was verified.
*
* <p>
* Paged rather than a JPQL {@code LIMIT}, which EclipseLink does not accept — the caller
* takes the first row.
* </p>
*/
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC")
List<MobileLookupLog> findLatestByMobileNumber(@Param("mobileNumber") String mobileNumber, Pageable pageable);
/**
* Earlier rejections of the same number, on any client. A number turned away once is a
* standing signal: without this the next officer screens it fresh and sees no history.
*/
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber "
+ "AND l.overrideDecision = 'REJECTED' ORDER BY l.decidedOn DESC")
List<MobileLookupLog> findRejections(@Param("mobileNumber") String mobileNumber, Pageable pageable);
} }
@@ -61,6 +61,65 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
@Column(name = "screened_on", nullable = false) @Column(name = "screened_on", nullable = false)
private OffsetDateTime screenedOn; private OffsetDateTime screenedOn;
// ── Alert disposition ─────────────────────────────────────────────────────
// A FLAGGED screening opens a case. It stays PENDING_REVIEW until an officer either
// documents it as a different person (FALSE_POSITIVE, effective only once the Principal
// Officer approves) or confirms it (CONFIRMED_MATCH, which freezes and gets reported).
@Column(name = "disposition", length = 30)
private String disposition;
@Column(name = "disposition_reason")
private String dispositionReason;
@Column(name = "reviewed_by")
private Long reviewedBy;
@Column(name = "reviewed_on")
private OffsetDateTime reviewedOn;
@Column(name = "approved_by")
private Long approvedBy;
@Column(name = "approved_on")
private OffsetDateTime approvedOn;
@Column(name = "fiu_str_reference", length = 100)
private String fiuStrReference;
@Column(name = "nodal_officer_informed_on")
private OffsetDateTime nodalOfficerInformedOn;
@Column(name = "frozen_account_ids", length = 1000)
private String frozenAccountIds;
@Column(name = "frozen_on")
private OffsetDateTime frozenOn;
// ── STR tracking ──────────────────────────────────────────────────────────
// A confirmed match is reportable to FIU-IND. Tracked here rather than as an AML alert: an
// AML alert is built around a monetary transaction, whereas this is triggered by who the
// customer is, so the two are surfaced as separate streams on the FIU screen.
/** NULL until approval; then PENDING_FILING → FILED. */
@Column(name = "str_status", length = 30)
private String strStatus;
@Column(name = "str_due_date")
private java.time.LocalDate strDueDate;
@Column(name = "str_raised_on")
private OffsetDateTime strRaisedOn;
public static final String STR_PENDING_FILING = "PENDING_FILING";
public static final String STR_FILED = "FILED";
public static final String RESULT_FLAGGED = "FLAGGED";
public static final String RESULT_CLEAR = "CLEAR";
public static final String DISPOSITION_PENDING = "PENDING_REVIEW";
public static final String DISPOSITION_FALSE_POSITIVE = "FALSE_POSITIVE";
public static final String DISPOSITION_CONFIRMED = "CONFIRMED_MATCH";
protected SanctionsScreeningLog() {} protected SanctionsScreeningLog() {}
public SanctionsScreeningLog(final String screenedName, final String screeningResult, public SanctionsScreeningLog(final String screenedName, final String screeningResult,
@@ -75,8 +134,75 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
this.clientId = clientId; this.clientId = clientId;
this.ipAddress = ipAddress; this.ipAddress = ipAddress;
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant(); this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
// A hit opens a case; a clear screening has no case to close.
this.disposition = RESULT_FLAGGED.equals(screeningResult) ? DISPOSITION_PENDING : null;
} }
// ── Alert lifecycle ───────────────────────────────────────────────────────
/**
* Records the officer's assessment of the hit. Deliberately does NOT clear the block: a
* false positive only takes effect once {@link #approve(Long)} is called by the Principal
* Officer, so the person who raised it cannot wave their own match through.
*/
public void recordDisposition(final String newDisposition, final String reason, final Long userId) {
this.disposition = newDisposition;
this.dispositionReason = reason;
this.reviewedBy = userId;
this.reviewedOn = DateUtils.getOffsetDateTimeOfTenant();
// Re-opening a previously approved alert must drop the old approval with it.
this.approvedBy = null;
this.approvedOn = null;
}
public void approve(final Long approverId) {
this.approvedBy = approverId;
this.approvedOn = DateUtils.getOffsetDateTimeOfTenant();
}
/** Records the FIU-IND STR reference and the date the nodal officer was informed. */
public void recordReporting(final String strReference, final OffsetDateTime nodalOfficerInformedOn) {
if (strReference != null) {
this.fiuStrReference = strReference;
// Recording the FIU-IND acknowledgement is what closes the filing obligation, so the
// case stops appearing as outstanding on the FIU screen.
if (!strReference.isBlank()) {
this.strStatus = STR_FILED;
}
}
if (nodalOfficerInformedOn != null) {
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
}
}
public void recordFreeze(final String accountIds) {
this.frozenAccountIds = accountIds;
this.frozenOn = DateUtils.getOffsetDateTimeOfTenant();
}
/**
* Opens the STR obligation on approval of a confirmed match. PMLA allows seven working days
* from forming the suspicion; seven calendar days is the stricter reading.
*/
public void raiseStr() {
if (this.strStatus != null) {
return;
}
this.strStatus = STR_PENDING_FILING;
this.strRaisedOn = DateUtils.getOffsetDateTimeOfTenant();
this.strDueDate = DateUtils.getBusinessLocalDate().plusDays(7);
}
public boolean isStrPending() { return STR_PENDING_FILING.equals(this.strStatus); }
public boolean isFlagged() { return RESULT_FLAGGED.equals(this.screeningResult); }
public boolean isApprovedFalsePositive() {
return DISPOSITION_FALSE_POSITIVE.equals(this.disposition) && this.approvedBy != null;
}
public boolean isConfirmedMatch() { return DISPOSITION_CONFIRMED.equals(this.disposition); }
// ── Accessors ───────────────────────────────────────────────────────────── // ── Accessors ─────────────────────────────────────────────────────────────
public String getScreenedName() { return screenedName; } public String getScreenedName() { return screenedName; }
@@ -89,4 +215,17 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
public void setClientId(Long clientId) { this.clientId = clientId; } public void setClientId(Long clientId) { this.clientId = clientId; }
public String getIpAddress() { return ipAddress; } public String getIpAddress() { return ipAddress; }
public OffsetDateTime getScreenedOn() { return screenedOn; } public OffsetDateTime getScreenedOn() { return screenedOn; }
public String getDisposition() { return disposition; }
public String getDispositionReason() { return dispositionReason; }
public Long getReviewedBy() { return reviewedBy; }
public OffsetDateTime getReviewedOn() { return reviewedOn; }
public Long getApprovedBy() { return approvedBy; }
public OffsetDateTime getApprovedOn() { return approvedOn; }
public String getFiuStrReference() { return fiuStrReference; }
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
public String getFrozenAccountIds() { return frozenAccountIds; }
public OffsetDateTime getFrozenOn() { return frozenOn; }
public String getStrStatus() { return strStatus; }
public java.time.LocalDate getStrDueDate() { return strDueDate; }
public OffsetDateTime getStrRaisedOn() { return strRaisedOn; }
} }
@@ -39,4 +39,32 @@ public interface SanctionsScreeningLogRepository extends JpaRepository<Sanctions
@Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC") @Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name); List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition = :disposition ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findByDisposition(@Param("disposition") String disposition);
/** Every open alert — the Principal Officer's review queue. */
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition IS NOT NULL ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findAllAlerts();
/**
* A name is only cleared to onboard once a false positive on that exact name has been
* approved by the Principal Officer — the maker's own assessment is not enough.
*/
@Query("SELECT COUNT(l) FROM SanctionsScreeningLog l WHERE LOWER(TRIM(l.screenedName)) = LOWER(TRIM(:name)) "
+ "AND l.disposition = 'FALSE_POSITIVE' AND l.approvedBy IS NOT NULL")
long countApprovedFalsePositives(@Param("name") String name);
/** Latest alert for a client, used to decide whether their accounts must stay frozen. */
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
+ "ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findFlaggedByClientId(@Param("clientId") Long clientId);
/**
* Confirmed matches carrying an STR obligation — the sanctions stream on the FIU-IND screen.
* Pass null for every STR regardless of whether it has been filed.
*/
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.strStatus IS NOT NULL "
+ "AND (:status IS NULL OR l.strStatus = :status) ORDER BY l.strDueDate ASC, l.strRaisedOn DESC")
List<SanctionsScreeningLog> findStrCases(@Param("status") String status);
} }
@@ -275,6 +275,7 @@ public class SavingsApiConstants {
// Additional custom fields // Additional custom fields
public static final String balanceFrequencyParamName = "balanceFrequency"; public static final String balanceFrequencyParamName = "balanceFrequency";
public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing"; public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing";
public static final String productCategoryParamName = "productCategory";
// Current Product — new UI fields // Current Product — new UI fields
public static final String balanceSwapOptionParamName = "balanceSwapOption"; public static final String balanceSwapOptionParamName = "balanceSwapOption";
@@ -144,6 +144,7 @@ public final class SavingsProductData implements Serializable {
@Getter @Setter private Boolean enableLockinPeriod; @Getter @Setter private Boolean enableLockinPeriod;
@Getter @Setter private String balanceFrequency; @Getter @Setter private String balanceFrequency;
@Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing; @Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing;
@Getter @Setter private String productCategory;
// New current product fields // New current product fields
@Getter @Setter private Boolean balanceSwapOption; @Getter @Setter private Boolean balanceSwapOption;
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.infrastructure.contentstore.policy; package org.apache.fineract.infrastructure.contentstore.policy;
import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.Strings;
@@ -31,6 +32,10 @@ import org.springframework.stereotype.Component;
@Component @Component
public class MimeContentPolicy implements ContentPolicy { public class MimeContentPolicy implements ContentPolicy {
// CSV has no content-level magic bytes, so byte-sniffing a delimited text file legitimately
// detects it as text/plain even though it was uploaded (and named) as text/csv.
private static final Set<String> INTERCHANGEABLE_TEXT_MIME_TYPES = Set.of("text/csv", "text/plain");
private final ContentDetectorManager contentDetectorManager; private final ContentDetectorManager contentDetectorManager;
@Override @Override
@@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy {
var result = contentDetectorManager var result = contentDetectorManager
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build()); .detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
if (!Strings.CI.equals(result.getMimeType(), ctx.getMimeType())) { boolean matches = Strings.CI.equals(result.getMimeType(), ctx.getMimeType())
|| (INTERCHANGEABLE_TEXT_MIME_TYPES.contains(result.getMimeType())
&& INTERCHANGEABLE_TEXT_MIME_TYPES.contains(ctx.getMimeType()));
if (!matches) {
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!", throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
result.getMimeType(), ctx.getMimeType())); result.getMimeType(), ctx.getMimeType()));
} }
@@ -0,0 +1,131 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.dividendpaidreport.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* "Total Dividend Paid" report — derived entirely from the existing Dividend Payable GL
* account's debit-side journal entries, exactly how real-world CBS/UCB systems report this (a
* query over the ledger, not a separate "Dividend Paid" account). Dividend Payable is only ever
* debited by an actual member payout (see ShareAccountSchedularServiceImpl.postDividend — via
* the native DIVIDEND_PAYOUT savings transaction's accounting), so every debit entry against it
* is, by construction, a real dividend payment.
*/
@Path("/v1/accounting/dividend-paid-report")
@Component
@Tag(name = "Dividend Paid Report", description = "Total share dividend actually paid to members in a date range, derived from the Dividend Payable GL account's ledger.")
@RequiredArgsConstructor
public class DividendPaidReportApiResource {
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getDividendPaidReport(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
final Long payableDividendsAccountId = jdbcTemplate
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
.stream().findFirst().orElse(null);
final JsonObject result = new JsonObject();
result.addProperty("fromDate", fromDate);
result.addProperty("toDate", toDate);
if (payableDividendsAccountId == null) {
// Dividend Payable hasn't been mapped under Accounting -> Financial Activity Mappings yet.
result.addProperty("totalPaid", BigDecimal.ZERO);
result.addProperty("payoutCount", 0);
result.add("entries", new JsonArray());
result.addProperty("glAccountConfigured", false);
return gson.toJson(result);
}
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
SELECT COALESCE(SUM(amount), 0) AS total_paid, COUNT(*) AS payout_count
FROM acc_gl_journal_entry
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
final List<Map<String, Object>> entryRows = jdbcTemplate.queryForList("""
SELECT entry_date, amount, transaction_id
FROM acc_gl_journal_entry
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
ORDER BY entry_date DESC
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
result.addProperty("totalPaid", (BigDecimal) totals.get("total_paid"));
result.addProperty("payoutCount", ((Number) totals.get("payout_count")).intValue());
result.addProperty("glAccountConfigured", true);
final JsonArray entries = new JsonArray();
for (final Map<String, Object> row : entryRows) {
final JsonObject entry = new JsonObject();
entry.addProperty("entryDate", row.get("entry_date").toString());
entry.addProperty("amount", (BigDecimal) row.get("amount"));
entry.addProperty("transactionId", (String) row.get("transaction_id"));
entries.add(entry);
}
result.add("entries", entries);
return gson.toJson(result);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject errorBody = new JsonObject();
errorBody.addProperty("message", message);
errorBody.addProperty("defaultUserMessage", message);
errorBody.addProperty("httpStatus", httpStatus);
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,98 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.profitandloss.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Real Profit &amp; Loss (Net Profit) for a date range, computed live from posted General Ledger
* journal entries — not the demo/sample data shown by the GL Reports module. Used by the Share
* Dividend "Create Dividend" screen so a bank officer can see actual profit for the dividend
* period and set the dividend as a percentage of it, instead of typing a total amount blind.
*
* Standard double-entry rule: an Income account (classification_enum=4) increases with a CREDIT
* and decreases with a DEBIT; an Expense account (classification_enum=5) increases with a DEBIT
* and decreases with a CREDIT. Reversed entries are excluded.
*/
@Path("/v1/accounting/profitandloss")
@Component
@Tag(name = "Profit And Loss", description = "Real net profit for a date range, computed from posted GL journal entries.")
@RequiredArgsConstructor
public class ProfitAndLossApiResource {
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getProfitAndLoss(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false
AND je.entry_date BETWEEN ? AND ?
""", from, to);
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
return gson.toJson(Map.of("fromDate", fromDate, "toDate", toDate, "totalIncome", totalIncome, "totalExpense", totalExpense,
"netProfit", netProfit));
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,114 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.databaseexport.api;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.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 jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.databaseexport.service.DatabaseExportService;
import org.springframework.stereotype.Component;
@Path("/v1/databaseexport")
@Component
@Tag(name = "Database Export", description = "Approval-gated full export of the tenant database.")
@RequiredArgsConstructor
public class DatabaseExportApiResource {
private final DatabaseExportService service;
private final FromJsonHelper fromJsonHelper;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List export requests, optionally filtered by status")
public List<Map<String, Object>> retrieveAll(@QueryParam("status") @Parameter(description = "status") final String status) {
return this.service.retrieveAll(status);
}
@GET
@Path("{requestId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a single export request")
public Map<String, Object> retrieveOne(@PathParam("requestId") final Long requestId) {
return this.service.retrieveOne(requestId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Raise a request for a full database export")
public Map<String, Object> requestExport(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final String reason = this.fromJsonHelper.extractStringNamed("reason", element);
final Long id = this.service.requestExport(reason);
return Map.of("resourceId", id);
}
@POST
@Path("{requestId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve an export request (admin only, never the requester)")
public Map<String, Object> approve(@PathParam("requestId") final Long requestId) {
this.service.approve(requestId);
return Map.of("resourceId", requestId, "status", "APPROVED");
}
@POST
@Path("{requestId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject an export request with a reason")
public Map<String, Object> reject(@PathParam("requestId") final Long requestId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonObject json = element == null ? new JsonObject() : element.getAsJsonObject();
final String reason = json.has("rejectionReason") && !json.get("rejectionReason").isJsonNull()
? json.get("rejectionReason").getAsString()
: null;
this.service.reject(requestId, reason);
return Map.of("resourceId", requestId, "status", "REJECTED");
}
@GET
@Path("{requestId}/download")
@Produces({ "application/zip" })
@Operation(summary = "Download the approved export as a ZIP of CSV files")
public Response download(@PathParam("requestId") final Long requestId) {
final byte[] archive = this.service.generateExport(requestId);
return Response.ok(archive)
.header("Content-Disposition", "attachment; filename=\"cbsnexora-database-export.zip\"")
.header("Content-Length", archive.length)
.build();
}
}
@@ -0,0 +1,152 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.databaseexport.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A request to take a full export of the tenant database. The export carries every client's personal, KYC and
* account data, so it is deliberately a two-person action: whoever asks for it cannot also approve it.
*/
@Entity
@Table(name = "m_database_export_request")
public class DatabaseExportRequest extends AbstractPersistableCustom<Long> {
public static final String STATUS_PENDING_APPROVAL = "PENDING_APPROVAL";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
/** Approved and the dump has actually been downloaded at least once. */
public static final String STATUS_GENERATED = "GENERATED";
@Column(name = "status", length = 30, nullable = false)
private String status;
@Column(name = "reason", nullable = false)
private String reason;
@Column(name = "requested_by", nullable = false)
private Long requestedBy;
@Column(name = "requested_by_username", length = 100)
private String requestedByUsername;
@Column(name = "requested_on", nullable = false)
private LocalDateTime requestedOn;
@Column(name = "approved_by")
private Long approvedBy;
@Column(name = "approved_by_username", length = 100)
private String approvedByUsername;
@Column(name = "approved_on")
private LocalDateTime approvedOn;
@Column(name = "rejection_reason")
private String rejectionReason;
@Column(name = "generated_on")
private LocalDateTime generatedOn;
@Column(name = "generated_by")
private Long generatedBy;
@Column(name = "file_name", length = 255)
private String fileName;
@Column(name = "file_size_bytes")
private Long fileSizeBytes;
@Column(name = "row_count")
private Long rowCount;
protected DatabaseExportRequest() {
// Default constructor for JPA
}
private DatabaseExportRequest(final String reason, final Long requestedBy, final String requestedByUsername,
final LocalDateTime requestedOn) {
this.status = STATUS_PENDING_APPROVAL;
this.reason = reason;
this.requestedBy = requestedBy;
this.requestedByUsername = requestedByUsername;
this.requestedOn = requestedOn;
}
public static DatabaseExportRequest createNew(final String reason, final Long requestedBy, final String requestedByUsername,
final LocalDateTime requestedOn) {
return new DatabaseExportRequest(reason, requestedBy, requestedByUsername, requestedOn);
}
public String getStatus() {
return this.status;
}
public Long getRequestedBy() {
return this.requestedBy;
}
public boolean isPendingApproval() {
return STATUS_PENDING_APPROVAL.equals(this.status);
}
/**
* An approval buys exactly one download. Once the archive has been handed over the request is spent, and a
* second copy has to be asked for and approved again — so every copy of the database that exists outside
* the system can be traced back to an approval that was granted for it specifically.
*/
public boolean isDownloadable() {
return STATUS_APPROVED.equals(this.status);
}
public boolean isAlreadyDownloaded() {
return STATUS_GENERATED.equals(this.status);
}
public void approve(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn) {
this.status = STATUS_APPROVED;
this.approvedBy = approvedBy;
this.approvedByUsername = approvedByUsername;
this.approvedOn = approvedOn;
}
public void reject(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn,
final String rejectionReason) {
this.status = STATUS_REJECTED;
this.approvedBy = approvedBy;
this.approvedByUsername = approvedByUsername;
this.approvedOn = approvedOn;
this.rejectionReason = rejectionReason;
}
/** Records that the dump actually left the system, which is the moment that matters for an audit. */
public void markGenerated(final Long generatedBy, final LocalDateTime generatedOn, final String fileName,
final Long fileSizeBytes, final Long rowCount) {
this.status = STATUS_GENERATED;
this.generatedBy = generatedBy;
this.generatedOn = generatedOn;
this.fileName = fileName;
this.fileSizeBytes = fileSizeBytes;
this.rowCount = rowCount;
}
}
@@ -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.infrastructure.databaseexport.domain;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface DatabaseExportRequestRepository extends JpaRepository<DatabaseExportRequest, Long> {
List<DatabaseExportRequest> findByStatusOrderByIdDesc(String status);
long countByRequestedByAndStatus(Long requestedBy, String status);
/**
* An approval is worth exactly one download, so the row is locked while that single use is spent. Without
* the lock two concurrent calls could both read APPROVED and both walk away with a copy of the database.
*/
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select request from DatabaseExportRequest request where request.id = :requestId")
Optional<DatabaseExportRequest> findByIdWithLock(@Param("requestId") Long requestId);
}
@@ -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.infrastructure.databaseexport.service;
import java.util.List;
import java.util.Map;
/** Requesting, approving and generating a full export of the tenant database. */
public interface DatabaseExportService {
Long requestExport(String reason);
List<Map<String, Object>> retrieveAll(String status);
Map<String, Object> retrieveOne(Long requestId);
void approve(Long requestId);
void reject(Long requestId, String rejectionReason);
/**
* Streams the approved export as a ZIP of CSV files, one per table. Only callable against a request that an
* admin has already approved.
*/
byte[] generateExport(Long requestId);
}
@@ -0,0 +1,318 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.databaseexport.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequest;
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequestRepository;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class DatabaseExportServiceImpl implements DatabaseExportService {
private static final String PERMISSION = "DATABASEEXPORT";
/** Guards against a runaway export: a single table beyond this is truncated rather than exhausting heap. */
private static final int MAX_ROWS_PER_TABLE = 200_000;
private final PlatformSecurityContext context;
private final DatabaseExportRequestRepository repository;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
public Long requestExport(final String reason) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_" + PERMISSION);
if (reason == null || reason.trim().length() < 10) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.reason.too.short",
"A reason of at least 10 characters is required for a database export request.");
}
// One open request per user at a time, so a queue of forgotten requests cannot build up for an
// approver to rubber-stamp in bulk.
if (this.repository.countByRequestedByAndStatus(user.getId(), DatabaseExportRequest.STATUS_PENDING_APPROVAL) > 0) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.request.already.pending",
"You already have a database export request awaiting approval.");
}
final DatabaseExportRequest request = DatabaseExportRequest.createNew(reason.trim(), user.getId(), user.getUsername(),
LocalDateTime.now());
this.repository.saveAndFlush(request);
log.warn("Database export requested by user {} ({}), request id {}", user.getUsername(), user.getId(), request.getId());
return request.getId();
}
@Override
public List<Map<String, Object>> retrieveAll(final String status) {
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
// Aliases are quoted because PostgreSQL folds unquoted identifiers to lower case, which would hand the
// client `requestedbyusername` and leave every camelCase field on the screen empty.
final StringBuilder sql = new StringBuilder("SELECT id, status, reason, requested_by AS \"requestedBy\", "
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
+ "approved_by AS \"approvedBy\", approved_by_username AS \"approvedByUsername\", "
+ "approved_on AS \"approvedOn\", rejection_reason AS \"rejectionReason\", "
+ "generated_on AS \"generatedOn\", generated_by AS \"generatedBy\", file_name AS \"fileName\", "
+ "file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
+ "FROM m_database_export_request ");
if (status != null && !status.trim().isEmpty()) {
sql.append("WHERE status = ? ORDER BY id DESC");
return this.jdbcTemplate.queryForList(sql.toString(), status.trim().toUpperCase());
}
sql.append("ORDER BY id DESC");
return this.jdbcTemplate.queryForList(sql.toString());
}
@Override
public Map<String, Object> retrieveOne(final Long requestId) {
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id, status, reason, requested_by AS \"requestedBy\", "
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
+ "approved_by_username AS \"approvedByUsername\", approved_on AS \"approvedOn\", "
+ "rejection_reason AS \"rejectionReason\", generated_on AS \"generatedOn\", "
+ "file_name AS \"fileName\", file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
+ "FROM m_database_export_request WHERE id = ?",
requestId);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.found", "Export request not found.");
}
return rows.get(0);
}
@Transactional
@Override
public void approve(final Long requestId) {
final AppUser approver = this.context.authenticatedUser();
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
final DatabaseExportRequest request = findOneOrThrow(requestId);
validatePending(request);
validateNotSelfApproval(request, approver);
request.approve(approver.getId(), approver.getUsername(), LocalDateTime.now());
this.repository.saveAndFlush(request);
log.warn("Database export request {} approved by {} ({})", requestId, approver.getUsername(), approver.getId());
}
@Transactional
@Override
public void reject(final Long requestId, final String rejectionReason) {
final AppUser approver = this.context.authenticatedUser();
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
final DatabaseExportRequest request = findOneOrThrow(requestId);
validatePending(request);
if (rejectionReason == null || rejectionReason.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.rejection.reason.required",
"A reason is required when rejecting a database export request.");
}
request.reject(approver.getId(), approver.getUsername(), LocalDateTime.now(), rejectionReason.trim());
this.repository.saveAndFlush(request);
}
@Transactional
@Override
public byte[] generateExport(final Long requestId) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("DOWNLOAD_" + PERMISSION);
// Locked for the duration of the transaction: the approval is worth one download, and two concurrent
// calls must not both find it unspent.
final DatabaseExportRequest request = this.repository.findByIdWithLock(requestId)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.databaseexport.not.found",
"Export request not found."));
// Checked before ownership so the requester coming back for a second copy gets told why, rather than
// being turned away with a message about approval that no longer describes their situation.
if (request.isAlreadyDownloaded()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.downloaded",
"This export has already been downloaded. Raise a new request to take another copy.");
}
if (!request.isDownloadable()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.approved",
"This export request has not been approved. Current status: " + request.getStatus() + ".");
}
validateIsRequester(request, user);
final List<String> tables = listTenantTables();
long totalRows = 0;
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(buffer, StandardCharsets.UTF_8)) {
for (final String table : tables) {
try {
totalRows += writeTableAsCsv(zip, table);
} catch (final Exception e) {
// One unreadable table (a view, a permissions quirk) must not abandon the whole export.
log.error("Database export: skipping table {} — {}", table, e.getMessage());
}
}
writeManifest(zip, request, tables, totalRows, user);
} catch (final IOException e) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.generation.failed",
"Failed to generate the database export: " + e.getMessage());
}
final byte[] archive = buffer.toByteArray();
final String fileName = "cbsnexora-export-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now())
+ ".zip";
request.markGenerated(user.getId(), LocalDateTime.now(), fileName, (long) archive.length, totalRows);
this.repository.saveAndFlush(request);
log.warn("Database export request {} downloaded by {} ({}) — {} tables, {} rows, {} bytes", requestId, user.getUsername(),
user.getId(), tables.size(), totalRows, archive.length);
return archive;
}
private List<String> listTenantTables() {
return this.jdbcTemplate.queryForList("SELECT table_name FROM information_schema.tables "
+ "WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name", String.class);
}
/** Writes one table as a CSV entry and returns how many rows it contained. */
private long writeTableAsCsv(final ZipOutputStream zip, final String table) throws IOException {
// The table name comes from information_schema, never from user input, so it cannot carry injection.
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM \"" + table + "\" LIMIT " + MAX_ROWS_PER_TABLE);
zip.putNextEntry(new ZipEntry("data/" + table + ".csv"));
if (rows.isEmpty()) {
// Still emit the header so an empty table is visibly empty rather than missing.
final List<String> columns = this.jdbcTemplate.queryForList("SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position", String.class, table);
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
zip.write('\n');
zip.closeEntry();
return 0;
}
final List<String> columns = new ArrayList<>(rows.get(0).keySet());
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
zip.write('\n');
for (final Map<String, Object> row : rows) {
final StringBuilder line = new StringBuilder();
for (int i = 0; i < columns.size(); i++) {
if (i > 0) {
line.append(',');
}
line.append(csvEscape(row.get(columns.get(i))));
}
line.append('\n');
zip.write(line.toString().getBytes(StandardCharsets.UTF_8));
}
zip.closeEntry();
return rows.size();
}
/** Records who authorised the export inside the archive, so the file carries its own provenance. */
private void writeManifest(final ZipOutputStream zip, final DatabaseExportRequest request, final List<String> tables,
final long totalRows, final AppUser downloadedBy) throws IOException {
final Map<String, Object> details = retrieveOne(request.getId());
final StringBuilder manifest = new StringBuilder();
manifest.append("CBSNexora — Full Database Export\n");
manifest.append("=================================\n\n");
manifest.append("CONFIDENTIAL. This archive contains customer personal, KYC and account data.\n");
manifest.append("Handle under your institution's data protection policy.\n\n");
manifest.append("Request ID : ").append(request.getId()).append('\n');
manifest.append("Requested by : ").append(details.get("requestedByUsername")).append('\n');
manifest.append("Requested on : ").append(details.get("requestedOn")).append('\n');
manifest.append("Reason : ").append(details.get("reason")).append('\n');
manifest.append("Approved by : ").append(details.get("approvedByUsername")).append('\n');
manifest.append("Approved on : ").append(details.get("approvedOn")).append('\n');
manifest.append("Downloaded by : ").append(downloadedBy.getUsername()).append('\n');
manifest.append("Downloaded on : ").append(LocalDateTime.now()).append('\n');
manifest.append("Tables exported : ").append(tables.size()).append('\n');
manifest.append("Rows exported : ").append(totalRows).append('\n');
manifest.append("Row cap / table : ").append(MAX_ROWS_PER_TABLE).append('\n');
zip.putNextEntry(new ZipEntry("MANIFEST.txt"));
zip.write(manifest.toString().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
private String csvEscape(final Object value) {
if (value == null) {
return "";
}
final String text = String.valueOf(value);
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
return '"' + text.replace("\"", "\"\"") + '"';
}
return text;
}
private DatabaseExportRequest findOneOrThrow(final Long requestId) {
return this.repository.findById(requestId).orElseThrow(() -> new PlatformDataIntegrityException(
"error.msg.databaseexport.not.found", "Export request not found."));
}
private void validatePending(final DatabaseExportRequest request) {
if (!request.isPendingApproval()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.actioned",
"This request has already been actioned. Current status: " + request.getStatus() + ".");
}
}
/**
* The archive goes to the person who asked for it and stated the reason, and to nobody else. An approver
* authorises the export; that is not the same as being entitled to take a copy of it, and letting the
* approver download would collapse the two-person control back into one.
*/
private void validateIsRequester(final DatabaseExportRequest request, final AppUser user) {
if (!user.getId().equals(request.getRequestedBy())) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.requester",
"Only the user who raised this export request can download the archive.");
}
}
/** The entire control is that two different people are involved, so this is the check that enforces it. */
private void validateNotSelfApproval(final DatabaseExportRequest request, final AppUser approver) {
if (approver.getId().equals(request.getRequestedBy())) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.self.approval",
"A database export cannot be approved by the same user who requested it.");
}
}
}
@@ -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.organisation.agent.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.data.AgentData;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/agents")
@Component
@Tag(name = "DDS Agents", description = "A DDS collection agent - either a person or, at the top of the hierarchy, the bank itself - with its own settlement savings account and guarantors.")
@RequiredArgsConstructor
public class AgentsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "AGENT";
private final PlatformSecurityContext context;
private final AgentReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Agents", description = "Returns the list of DDS agents.")
public Collection<AgentData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Agent", description = "Returns the details of a DDS agent, including its guarantors.")
public AgentData retrieveOne(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(agentId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a DDS Agent", description = "Mandatory fields: clientId, agentNameId, agentAddress1, agentCity, "
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, homeBranch, "
+ "savingBranch, tdsApplicable, commissionCalculation\n" + "\n"
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, tdsPercent, transferBranch, "
+ "transferAmountAccountType, transferAccountNo, limit, machineRent, agentSecurityBranch, agentSecurityAccountType, "
+ "agentSecurityAccountNo, otherCommission, agentDeposit, festivalAdvance, otherDeduction, limits, guarantors")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createAgent() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@PUT
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a DDS Agent", description = "Updates the details of a DDS agent.")
public CommandProcessingResult update(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateAgent(agentId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
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;
public interface AgentRepository extends JpaRepository<Agent, Long> {
@Query("select coalesce(max(cast(substring(a.agentCode, 3) as long)), 0) from Agent a where a.agentCode like 'AG%'")
Long retrieveMaxAgentCodeSuffix();
Optional<Agent> findByAgentCode(String agentCode);
@Query("select a from Agent a where a.id between :agentFrom and :agentTo")
List<Agent> findBetweenIds(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "AGENT", action = "CREATE")
public class CreateAgentCommandHandler implements NewCommandSourceHandler {
private final AgentWritePlatformService writePlatformService;
@Autowired
public CreateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createAgent(command);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "AGENT", action = "UPDATE")
public class UpdateAgentCommandHandler implements NewCommandSourceHandler {
private final AgentWritePlatformService writePlatformService;
@Autowired
public UpdateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateAgent(command.entityId(), command);
}
}
@@ -0,0 +1,409 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Validates create/update commands for an Agent - the DDS collection agent. As of 2026-08-24, Agent
* does the daily collection work directly (the SubAgent tier was removed), so this deserializer carries
* the collection/commission/limit/security-account fields SubAgent used to own, alongside Agent's
* original identity/settlement-account fields.
*/
@Component
public final class AgentCommandFromApiJsonDeserializer {
public static final String CLIENT_ID = "clientId";
public static final String AGENT_NAME_ID = "agentNameId";
public static final String AGENT_ADDRESS1 = "agentAddress1";
public static final String AGENT_ADDRESS2 = "agentAddress2";
public static final String AGENT_CITY = "agentCity";
public static final String AGENT_PINCODE = "agentPinCode";
public static final String AGENT_TELEPHONE = "agentTelephone";
public static final String AGENT_SAVING_ACCOUNT_TYPE = "agentSavingAccountType";
public static final String AGENT_SAVING_ACCOUNT_NO = "agentSavingAccountNo";
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
public static final String HOME_BRANCH = "homeBranch";
public static final String SAVING_BRANCH = "savingBranch";
public static final String TDS_APPLICABLE = "tdsApplicable";
public static final String TDS_PERCENT = "tdsPercent";
public static final String TRANSFER_BRANCH = "transferBranch";
public static final String TRANSFER_AMOUNT_ACCOUNT_TYPE = "transferAmountAccountType";
public static final String TRANSFER_ACCOUNT_NO = "transferAccountNo";
public static final String COMMISSION_CALCULATION = "commissionCalculation";
public static final String LIMIT = "limit";
public static final String MACHINE_RENT = "machineRent";
public static final String AGENT_SECURITY_BRANCH = "agentSecurityBranch";
public static final String AGENT_SECURITY_ACCOUNT_TYPE = "agentSecurityAccountType";
public static final String AGENT_SECURITY_ACCOUNT_NO = "agentSecurityAccountNo";
public static final String OTHER_COMMISSION = "otherCommission";
public static final String AGENT_DEPOSIT = "agentDeposit";
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
public static final String OTHER_DEDUCTION = "otherDeduction";
public static final String LIMITS = "limits";
public static final String GUARANTORS = "guarantors";
public static final String DATE_FORMAT = "dateFormat";
public static final String LOCALE = "locale";
private static final String GUARANTOR_CLIENT_ID = "clientId";
private static final String GUARANTOR_NAME = "guarantorName";
private static final String GUARANTOR_ADDRESS1 = "address1";
private static final String GUARANTOR_ADDRESS2 = "address2";
private static final String GUARANTOR_CITY = "city";
private static final String GUARANTOR_PINCODE = "pinCode";
private static final String GUARANTOR_TELEPHONE = "telephone";
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(CLIENT_ID, AGENT_NAME_ID, AGENT_ADDRESS1,
AGENT_ADDRESS2, AGENT_CITY, AGENT_PINCODE, AGENT_TELEPHONE, AGENT_SAVING_ACCOUNT_TYPE, AGENT_SAVING_ACCOUNT_NO,
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, HOME_BRANCH, SAVING_BRANCH, TDS_APPLICABLE, TDS_PERCENT,
TRANSFER_BRANCH, TRANSFER_AMOUNT_ACCOUNT_TYPE, TRANSFER_ACCOUNT_NO, COMMISSION_CALCULATION, LIMIT, MACHINE_RENT,
AGENT_SECURITY_BRANCH, AGENT_SECURITY_ACCOUNT_TYPE, AGENT_SECURITY_ACCOUNT_NO, OTHER_COMMISSION, AGENT_DEPOSIT,
FESTIVAL_ADVANCE, OTHER_DEDUCTION, LIMITS, GUARANTORS, DATE_FORMAT, LOCALE));
private static final Set<String> SUPPORTED_LIMIT_PARAMETERS = new HashSet<>(Arrays.asList("fromDate", "toDate", "limit"));
private static final Set<String> SUPPORTED_GUARANTOR_PARAMETERS = new HashSet<>(Arrays.asList(GUARANTOR_CLIENT_ID, GUARANTOR_NAME,
GUARANTOR_ADDRESS1, GUARANTOR_ADDRESS2, GUARANTOR_CITY, GUARANTOR_PINCODE, GUARANTOR_TELEPHONE));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public AgentCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
final JsonElement element = this.fromApiJsonHelper.parse(json);
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
}
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
}
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
.notExceedingLengthOf(100);
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
}
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
baseDataValidator.reset().parameter(TDS_APPLICABLE).trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE,
element));
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
validateOptionalStrings(element, baseDataValidator);
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
validateLimits(element.getAsJsonObject(), dataValidationErrors);
}
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForUpdate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (this.fromApiJsonHelper.parameterExists(CLIENT_ID, element)) {
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_NAME_ID, element)) {
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS1, element)) {
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_CITY, element)) {
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_PINCODE, element)) {
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_TYPE, element)) {
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_NO, element)) {
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_DATE, element)) {
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(HOME_BRANCH, element)) {
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(SAVING_BRANCH, element)) {
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
baseDataValidator.reset().parameter(TDS_APPLICABLE)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE, element));
}
if (this.fromApiJsonHelper.parameterExists(COMMISSION_CALCULATION, element)) {
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
}
validateOptionalStrings(element, baseDataValidator);
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
validateLimits(element.getAsJsonObject(), dataValidationErrors);
}
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void validateOptionalStrings(final JsonElement element, final DataValidatorBuilder v) {
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
v.reset().parameter(TDS_PERCENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_BRANCH, element)) {
v.reset().parameter(TRANSFER_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_BRANCH, element))
.ignoreIfNull().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)) {
v.reset().parameter(TRANSFER_AMOUNT_ACCOUNT_TYPE)
.value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_ACCOUNT_NO, element)) {
v.reset().parameter(TRANSFER_ACCOUNT_NO).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_ACCOUNT_NO, element))
.ignoreIfNull().notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(LIMIT, element)) {
v.reset().parameter(LIMIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(LIMIT, element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(MACHINE_RENT, element)) {
v.reset().parameter(MACHINE_RENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(MACHINE_RENT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_BRANCH, element)) {
v.reset().parameter(AGENT_SECURITY_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_BRANCH, element))
.ignoreIfNull().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_TYPE, element)) {
v.reset().parameter(AGENT_SECURITY_ACCOUNT_TYPE)
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_NO, element)) {
v.reset().parameter(AGENT_SECURITY_ACCOUNT_NO)
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_NO, element)).ignoreIfNull()
.notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(OTHER_COMMISSION, element)) {
v.reset().parameter(OTHER_COMMISSION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_COMMISSION, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_DEPOSIT, element)) {
v.reset().parameter(AGENT_DEPOSIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_DEPOSIT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(FESTIVAL_ADVANCE, element)) {
v.reset().parameter(FESTIVAL_ADVANCE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FESTIVAL_ADVANCE, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(OTHER_DEDUCTION, element)) {
v.reset().parameter(OTHER_DEDUCTION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_DEDUCTION, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
}
private void validateLimits(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("agent.limit");
final var limitsArray = topLevelJson.getAsJsonArray(LIMITS);
for (int i = 0; i < limitsArray.size(); i++) {
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(limitJson, SUPPORTED_LIMIT_PARAMETERS);
v.reset().parameterAtIndexArray("fromDate", i).value(extractString(limitJson, "fromDate")).notBlank();
v.reset().parameterAtIndexArray("toDate", i).value(extractString(limitJson, "toDate")).notBlank();
v.reset().parameterAtIndexArray("limit", i).value(extractBigDecimal(limitJson, "limit")).notNull().zeroOrPositiveAmount();
}
}
private void validateGuarantors(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder guarantorValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent.guarantor");
final var guarantorsArray = topLevelJson.getAsJsonArray(GUARANTORS);
for (int i = 0; i < guarantorsArray.size(); i++) {
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(guarantorJson, SUPPORTED_GUARANTOR_PARAMETERS);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i)
.value(extractLong(guarantorJson, GUARANTOR_CLIENT_ID)).notNull().integerGreaterThanZero();
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_NAME, i).value(extractString(guarantorJson, GUARANTOR_NAME))
.notBlank().notExceedingLengthOf(100);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_ADDRESS1, i).value(extractString(guarantorJson, GUARANTOR_ADDRESS1))
.notBlank().notExceedingLengthOf(200);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CITY, i).value(extractString(guarantorJson, GUARANTOR_CITY))
.notBlank().notExceedingLengthOf(100);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_PINCODE, i).value(extractString(guarantorJson, GUARANTOR_PINCODE))
.notBlank().notExceedingLengthOf(20);
}
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private Long extractLong(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsLong() : null;
}
private java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -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.organisation.agent.service;
import java.util.Collection;
import org.apache.fineract.organisation.agent.data.AgentData;
public interface AgentReadPlatformService {
Collection<AgentData> retrieveAll();
AgentData retrieveOne(Long agentId);
}
@@ -0,0 +1,176 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.organisation.agent.data.AgentData;
import org.apache.fineract.organisation.agent.data.AgentGuarantorData;
import org.apache.fineract.organisation.agent.data.AgentLimitData;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
private static final AgentLimitMapper LIMIT_MAPPER = new AgentLimitMapper();
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
private static final class AgentMapper implements RowMapper<AgentData> {
public String schema() {
return " a.id as id, a.client_id as clientId, c.display_name as clientName, a.agent_code as agentCode, "
+ " a.agent_name as agentNameId, a.agent_address1 as agentAddress1, a.agent_address2 as agentAddress2, "
+ " a.agent_city as agentCity, a.agent_pincode as agentPinCode, a.agent_telephone as agentTelephone, "
+ " a.agent_saving_account_type as agentSavingAccountType, a.agent_saving_account_no as agentSavingAccountNo, "
+ " a.agent_opening_date as agentOpeningDate, a.agent_birth_date as agentBirthDate, "
+ " a.agent_current_balance as agentCurrentBalance, a.agent_opening_balance as agentOpeningBalance, "
+ " a.home_branch as homeBranch, a.saving_branch as savingBranch, a.tds_applicable as tdsApplicable, "
+ " a.tds_percent as tdsPercent, a.current_tds as currentTds, a.tds_amount as tdsAmount, "
+ " a.transfer_branch as transferBranch, a.transfer_amount_account_type as transferAmountAccountType, "
+ " a.transfer_account_no as transferAccountNo, a.collector_commission_calculation as commissionCalculation, "
+ " a.collector_limit_amount as limitAmount, a.machine_rent as machineRent, "
+ " a.agent_security_branch as agentSecurityBranch, a.agent_security_account_type as agentSecurityAccountType, "
+ " a.agent_security_account_no as agentSecurityAccountNo, a.commission as commission, "
+ " a.other_commission as otherCommission, a.agent_deposit as agentDeposit, "
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction "
+ " from m_dds_agent a join m_client c on c.id = a.client_id";
}
@Override
public AgentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return AgentData.instance(rs.getLong("id"), rs.getLong("clientId"), rs.getString("clientName"), rs.getString("agentCode"),
rs.getString("agentNameId"), rs.getString("agentAddress1"), rs.getString("agentAddress2"),
rs.getString("agentCity"), rs.getString("agentPinCode"), rs.getString("agentTelephone"),
rs.getString("agentSavingAccountType"), rs.getString("agentSavingAccountNo"),
JdbcSupport.getLocalDate(rs, "agentOpeningDate"), JdbcSupport.getLocalDate(rs, "agentBirthDate"),
rs.getBigDecimal("agentCurrentBalance"), rs.getBigDecimal("agentOpeningBalance"), rs.getString("homeBranch"),
rs.getString("savingBranch"), rs.getBoolean("tdsApplicable"), rs.getBigDecimal("tdsPercent"),
rs.getBigDecimal("currentTds"), rs.getBigDecimal("tdsAmount"), rs.getString("transferBranch"),
rs.getString("transferAmountAccountType"), rs.getString("transferAccountNo"),
rs.getBoolean("commissionCalculation"), rs.getBigDecimal("limitAmount"), rs.getBigDecimal("machineRent"),
rs.getString("agentSecurityBranch"), rs.getString("agentSecurityAccountType"),
rs.getString("agentSecurityAccountNo"), rs.getBigDecimal("commission"), rs.getBigDecimal("otherCommission"),
rs.getBigDecimal("agentDeposit"), rs.getBigDecimal("festivalAdvance"), rs.getBigDecimal("otherDeduction"),
new ArrayList<>(), new ArrayList<>());
}
}
private static final class AgentLimitMapper implements RowMapper<Map.Entry<Long, AgentLimitData>> {
public String schema() {
return " l.agent_id as agentId, l.id as id, l.from_date as fromDate, l.to_date as toDate, "
+ " l.limit_amount as limitAmount from m_dds_agent_limit l";
}
@Override
public Map.Entry<Long, AgentLimitData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long agentId = rs.getLong("agentId");
final AgentLimitData data = AgentLimitData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "fromDate"),
JdbcSupport.getLocalDate(rs, "toDate"), rs.getBigDecimal("limitAmount"));
return Map.entry(agentId, data);
}
}
private static final class AgentGuarantorMapper implements RowMapper<Map.Entry<Long, AgentGuarantorData>> {
public String schema() {
return " g.agent_id as agentId, g.id as id, g.client_id as clientId, g.guarantor_name as guarantorName, "
+ " g.address1 as address1, g.address2 as address2, g.city as city, g.pin_code as pinCode, "
+ " g.telephone as telephone from m_dds_agent_guarantor g";
}
@Override
public Map.Entry<Long, AgentGuarantorData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long agentId = rs.getLong("agentId");
final AgentGuarantorData data = AgentGuarantorData.instance(rs.getLong("id"), rs.getLong("clientId"),
rs.getString("guarantorName"), rs.getString("address1"), rs.getString("address2"), rs.getString("city"),
rs.getString("pinCode"), rs.getString("telephone"));
return Map.entry(agentId, data);
}
}
@Override
public Collection<AgentData> retrieveAll() {
final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id";
final List<AgentData> agents = this.jdbcTemplate.query(sql, AGENT_MAPPER); // NOSONAR
return attachChildren(agents);
}
@Override
public AgentData retrieveOne(final Long agentId) {
try {
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
return attachChildren(List.of(agent)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new AgentNotFoundException(agentId, e);
}
}
private Collection<AgentData> attachChildren(final List<AgentData> agents) {
if (agents.isEmpty()) {
return agents;
}
final String limitSql = "select " + LIMIT_MAPPER.schema() + " order by l.id";
final List<Map.Entry<Long, AgentLimitData>> limitRows = this.jdbcTemplate.query(limitSql, LIMIT_MAPPER); // NOSONAR
final Map<Long, List<AgentLimitData>> limitsByAgentId = new LinkedHashMap<>();
for (final Map.Entry<Long, AgentLimitData> row : limitRows) {
limitsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id";
final List<Map.Entry<Long, AgentGuarantorData>> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR
final Map<Long, List<AgentGuarantorData>> guarantorsByAgentId = new LinkedHashMap<>();
for (final Map.Entry<Long, AgentGuarantorData> row : guarantorRows) {
guarantorsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<AgentData> result = new ArrayList<>(agents.size());
for (final AgentData agent : agents) {
final List<AgentLimitData> limits = limitsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
final List<AgentGuarantorData> guarantors = guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
result.add(AgentData.instance(agent.getId(), agent.getClientId(), agent.getClientName(), agent.getAgentCode(),
agent.getAgentNameId(), agent.getAgentAddress1(), agent.getAgentAddress2(), agent.getAgentCity(),
agent.getAgentPinCode(), agent.getAgentTelephone(), agent.getAgentSavingAccountType(),
agent.getAgentSavingAccountNo(), agent.getAgentOpeningDate(), agent.getAgentBirthDate(),
agent.getAgentCurrentBalance(), agent.getAgentOpeningBalance(), agent.getHomeBranch(), agent.getSavingBranch(),
agent.getTdsApplicable(), agent.getTdsPercent(), agent.getCurrentTds(), agent.getTdsAmount(),
agent.getTransferBranch(), agent.getTransferAmountAccountType(), agent.getTransferAccountNo(),
agent.getCommissionCalculation(), agent.getLimit(), agent.getMachineRent(), agent.getAgentSecurityBranch(),
agent.getAgentSecurityAccountType(), agent.getAgentSecurityAccountNo(), agent.getCommission(),
agent.getOtherCommission(), agent.getAgentDeposit(), agent.getFestivalAdvance(), agent.getOtherDeduction(),
limits, guarantors));
}
return result;
}
}
@@ -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.organisation.agent.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface AgentWritePlatformService {
CommandProcessingResult createAgent(JsonCommand command);
CommandProcessingResult updateAgent(Long agentId, JsonCommand command);
}
@@ -0,0 +1,187 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
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.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
import org.apache.fineract.organisation.agent.domain.AgentLimit;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePlatformService {
private final AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final AgentRepository agentRepository;
private final ClientRepositoryWrapper clientRepositoryWrapper;
@Transactional
@Override
public CommandProcessingResult createAgent(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final Long clientId = command.longValueOfParameterNamed("clientId");
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final String agentCode = generateNextAgentCode();
final Agent agent = Agent.fromJson(client, agentCode, command);
addLimitsFromCommand(agent, command);
addGuarantorsFromCommand(agent, command);
this.agentRepository.saveAndFlush(agent);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(agent.getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleAgentDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
@Transactional
@Override
public CommandProcessingResult updateAgent(final Long agentId, final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForUpdate(command.json());
final Agent agentForUpdate = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
final Map<String, Object> changesOnly = agentForUpdate.update(command);
if (changesOnly.containsKey("clientId")) {
final Long newClientId = (Long) changesOnly.get("clientId");
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
agentForUpdate.changeClient(newClient);
}
if (command.parameterExists("limits")) {
agentForUpdate.resetLimits();
addLimitsFromCommand(agentForUpdate, command);
changesOnly.put("limits", command.jsonFragment("limits"));
}
if (command.parameterExists("guarantors")) {
agentForUpdate.resetGuarantors();
addGuarantorsFromCommand(agentForUpdate, command);
changesOnly.put("guarantors", command.jsonFragment("guarantors"));
}
this.agentRepository.saveAndFlush(agentForUpdate);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).with(changesOnly)
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleAgentDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
private void addLimitsFromCommand(final Agent agent, final JsonCommand command) {
if (!command.parameterExists("limits")) {
return;
}
final JsonArray limitsArray = command.arrayOfParameterNamed("limits");
if (limitsArray == null) {
return;
}
for (int i = 0; i < limitsArray.size(); i++) {
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
final LocalDate fromDate = LocalDate.parse(limitJson.get("fromDate").getAsString());
final LocalDate toDate = LocalDate.parse(limitJson.get("toDate").getAsString());
final BigDecimal limit = limitJson.get("limit").getAsBigDecimal();
agent.addLimit(new AgentLimit(fromDate, toDate, limit));
}
}
private void addGuarantorsFromCommand(final Agent agent, final JsonCommand command) {
if (!command.parameterExists("guarantors")) {
return;
}
final JsonArray guarantorsArray = command.arrayOfParameterNamed("guarantors");
if (guarantorsArray == null) {
return;
}
for (int i = 0; i < guarantorsArray.size(); i++) {
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
final Long guarantorClientId = guarantorJson.get("clientId").getAsLong();
final String guarantorName = guarantorJson.get("guarantorName").getAsString();
final String address1 = guarantorJson.get("address1").getAsString();
final String address2 = guarantorJson.has("address2") && !guarantorJson.get("address2").isJsonNull()
? guarantorJson.get("address2").getAsString()
: null;
final String city = guarantorJson.get("city").getAsString();
final String pinCode = guarantorJson.get("pinCode").getAsString();
final String telephone = guarantorJson.has("telephone") && !guarantorJson.get("telephone").isJsonNull()
? guarantorJson.get("telephone").getAsString()
: null;
agent.addGuarantor(new AgentGuarantor(guarantorClientId, guarantorName, address1, address2, city, pinCode, telephone));
}
}
private String generateNextAgentCode() {
final Long maxSuffix = this.agentRepository.retrieveMaxAgentCodeSuffix();
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
return String.format("AG%04d", nextSuffix);
}
private void handleAgentDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("agent_code")) {
throw new PlatformDataIntegrityException("error.msg.agent.duplicate.agentCode", "An agent with the generated code already exists, please retry");
}
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.agent.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + realCause.getMessage());
}
}
@@ -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.organisation.agent.starter;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformServiceImpl;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class OrganisationAgentConfiguration {
@Bean
@ConditionalOnMissingBean(AgentReadPlatformService.class)
public AgentReadPlatformService agentReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AgentReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AgentWritePlatformService.class)
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper) {
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper);
}
}
@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.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.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/commissionslabs")
@Component
@Tag(name = "DDS Commission Slabs", description = "The agent commission chart: ACCOUNT slabs (commission percentage on collection volume) and COMMISSION slabs (the override a senior agent earns on a sub-agent's commission). Append-only reference data - no update or delete API.")
@RequiredArgsConstructor
public class CommissionSlabsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "COMMISSIONSLAB";
private final PlatformSecurityContext context;
private final CommissionSlabReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Commission Slabs", description = "Returns the full commission slab chart.")
public Collection<CommissionSlabData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Add DDS Commission Slabs", description = "Mandatory fields: slabs (array of {slabType: ACCOUNT|COMMISSION, "
+ "slabFrom, slabTo, commissionPercent}). Adds new slab rows to the chart - it does not replace or edit existing rows.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createCommissionSlabs() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -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.organisation.commissionslab.domain;
import java.math.BigDecimal;
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 CommissionSlabRepository extends JpaRepository<CommissionSlab, Long> {
@Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo")
List<CommissionSlab> findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount);
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "COMMISSIONSLAB", action = "CREATE")
public class CreateCommissionSlabsCommandHandler implements NewCommandSourceHandler {
private final CommissionSlabWritePlatformService writePlatformService;
@Autowired
public CreateCommissionSlabsCommandHandler(final CommissionSlabWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createCommissionSlabs(command);
}
}
@@ -0,0 +1,113 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class CommissionSlabCommandFromApiJsonDeserializer {
private static final Set<String> SUPPORTED_SLAB_TYPES = new HashSet<>(Arrays.asList("ACCOUNT", "COMMISSION"));
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_SLAB_PARAMETERS = new HashSet<>(
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public CommissionSlabCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (!this.fromApiJsonHelper.parameterExists("slabs", element)) {
v.reset().parameter("slabs").value(null).notNull();
} else {
final JsonObject topLevel = element.getAsJsonObject();
final var slabsArray = topLevel.getAsJsonArray("slabs");
v.reset().parameter("slabs").value(slabsArray).jsonArrayNotEmpty();
for (int i = 0; i < slabsArray.size(); i++) {
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(slabJson, SUPPORTED_SLAB_PARAMETERS);
final String slabType = extractString(slabJson, "slabType");
v.reset().parameterAtIndexArray("slabType", i).value(slabType).notBlank()
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES.toArray());
v.reset().parameterAtIndexArray("slabFrom", i).value(extractBigDecimal(slabJson, "slabFrom")).notNull()
.zeroOrPositiveAmount();
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
.zeroOrPositiveAmount();
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
.notNull().zeroOrPositiveAmount();
}
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -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.organisation.commissionslab.service;
import java.util.Collection;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
public interface CommissionSlabReadPlatformService {
Collection<CommissionSlabData> retrieveAll();
}
@@ -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.organisation.commissionslab.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class CommissionSlabReadPlatformServiceImpl implements CommissionSlabReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final CommissionSlabMapper MAPPER = new CommissionSlabMapper();
private static final class CommissionSlabMapper implements RowMapper<CommissionSlabData> {
public String schema() {
return " s.id as id, s.slab_type as slabType, s.slab_from as slabFrom, s.slab_to as slabTo, "
+ " s.commission_percent as commissionPercent from m_dds_commission_slab s";
}
@Override
public CommissionSlabData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return CommissionSlabData.builder() //
.id(rs.getLong("id")) //
.slabType(rs.getString("slabType")) //
.slabFrom(rs.getBigDecimal("slabFrom")) //
.slabTo(rs.getBigDecimal("slabTo")) //
.commissionPercent(rs.getBigDecimal("commissionPercent")) //
.build();
}
}
@Override
public List<CommissionSlabData> retrieveAll() {
final String sql = "select " + MAPPER.schema() + " order by s.slab_type, s.slab_from";
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
}
}
@@ -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.organisation.commissionslab.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface CommissionSlabWritePlatformService {
CommandProcessingResult createCommissionSlabs(JsonCommand command);
}
@@ -0,0 +1,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
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.ErrorHandler;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements CommissionSlabWritePlatformService {
private final CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final CommissionSlabRepository commissionSlabRepository;
@Transactional
@Override
public CommandProcessingResult createCommissionSlabs(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final List<CommissionSlab> slabs = new ArrayList<>();
final JsonArray slabsArray = command.arrayOfParameterNamed("slabs");
for (int i = 0; i < slabsArray.size(); i++) {
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
slabs.add(new CommissionSlab(slabJson.get("slabType").getAsString(), slabJson.get("slabFrom").getAsBigDecimal(),
slabJson.get("slabTo").getAsBigDecimal(), slabJson.get("commissionPercent").getAsBigDecimal()));
}
this.commissionSlabRepository.saveAllAndFlush(slabs);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(slabs.isEmpty() ? null : slabs.get(0).getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
} catch (final PersistenceException dve) {
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
}
}
}
@@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.starter;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformServiceImpl;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformServiceJpaRepositoryImpl;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class OrganisationCommissionSlabConfiguration {
@Bean
@ConditionalOnMissingBean(CommissionSlabReadPlatformService.class)
public CommissionSlabReadPlatformService commissionSlabReadPlatformService(JdbcTemplate jdbcTemplate) {
return new CommissionSlabReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(CommissionSlabWritePlatformService.class)
public CommissionSlabWritePlatformService commissionSlabWritePlatformService(
CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer, CommissionSlabRepository commissionSlabRepository) {
return new CommissionSlabWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, commissionSlabRepository);
}
}
@@ -797,7 +797,9 @@ public class ClerkWithdrawalRequestApiResource {
} }
/** /**
* Ensures the token number is unique for today's date among non-rejected requests. * Ensures the token number is unique for today's date among non-rejected requests, checking
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
* {@code excludeId} is set during resubmit to skip the record being edited. * {@code excludeId} is set during resubmit to skip the record being edited.
*/ */
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) { private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
@@ -810,10 +812,16 @@ public class ClerkWithdrawalRequestApiResource {
sql.append(" AND id <> ?"); sql.append(" AND id <> ?");
params.add(excludeId); params.add(excludeId);
} }
final long count = Optional.ofNullable( final long ownCount = Optional.ofNullable(
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray()) jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
).orElse(0L); ).orElse(0L);
if (count > 0) { final long otherCount = Optional.ofNullable(
jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
Long.class, tokNo)
).orElse(0L);
if (ownCount + otherCount > 0) {
throw apiError(400, throw apiError(400,
"Token number '" + tokNo + "' is already used today. Please enter a different token number."); "Token number '" + tokNo + "' is already used today. Please enter a different token number.");
} }
@@ -111,12 +111,16 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
@Autowired @Autowired
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService, public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
final EmailTemplateService emailTemplateService) { final EmailTemplateService emailTemplateService,
final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService) {
this.restTemplate = new RestTemplate(); this.restTemplate = new RestTemplate();
this.otpLogRepository = otpLogRepository; this.otpLogRepository = otpLogRepository;
this.emailSenderService = emailSenderService; this.emailSenderService = emailSenderService;
this.emailTemplateService = emailTemplateService; this.emailTemplateService = emailTemplateService;
this.mobileLookupService = mobileLookupService;
} }
// ── Generate ───────────────────────────────────────────────────────────── // ── Generate ─────────────────────────────────────────────────────────────
@@ -128,6 +132,18 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build(); return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
} }
// A number the DoT flagged may already be in someone else's hands — an MNRL number is one
// that was disconnected and can be reassigned. Sending an OTP there hands the fraudster the
// credential, so this is refused at the point of dispatch rather than merely warned about
// on screen. Only an officer's recorded PROCEEDED decision reopens it.
if (!this.mobileLookupService.isMobileNumberUsable(mobileNumber.trim())) {
LOG.warn("Refused to send OTP to DIP/MNRL-flagged number {}", OtpDestinationMasker.maskMobile(mobileNumber));
return OtpGenerateResponse.builder().success(false)
.message("This mobile number is flagged on the DoT Digital Intelligence Platform. "
+ "Contact the customer through an alternate channel and obtain a new number.")
.build();
}
final OtpLog log = new OtpLog(); final OtpLog log = new OtpLog();
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE); log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
log.setProvider(PROVIDER_LINCPAY); log.setProvider(PROVIDER_LINCPAY);
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>( protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName, incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
authorizedSignatoryParamName, additionalInfoParamName, dateOfRegistrationParamName, datatables)); authorizedSignatoryParamName, authorizedSignatoryClientIdsParamName, additionalInfoParamName,
dateOfRegistrationParamName, datatables));
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName, protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName, dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
@@ -70,8 +71,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>( protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName, Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName, additionalInfoParamName, constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName,
dateOfRegistrationParamName)); authorizedSignatoryClientIdsParamName, additionalInfoParamName, dateOfRegistrationParamName));
/** /**
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get * These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
@@ -87,6 +87,11 @@ public final class ClientKycData {
private final Boolean cinVerified; private final Boolean cinVerified;
private final LocalDate cinVerifiedOn; private final LocalDate cinVerifiedOn;
private final LocalDate incorporationDate; private final LocalDate incorporationDate;
// DIN of a director — read back so an entity re-opened for edit can show the verification
// already obtained for each authorized signatory instead of asking for it again.
private final String dinNumber;
private final Boolean dinVerified;
private final LocalDate dinVerifiedOn;
// ── KYC Document ───────────────────────────────────────────────────────── // ── KYC Document ─────────────────────────────────────────────────────────
private final String kycDocumentName; private final String kycDocumentName;
@@ -75,6 +75,14 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
@Column(name = "authorized_signatory", length = 500, nullable = true) @Column(name = "authorized_signatory", length = 500, nullable = true)
private String authorizedSignatory; private String authorizedSignatory;
/**
* Client ids of the signatories named in {@link #authorizedSignatory}, comma separated and in the
* same order. Kept so re-opening the client can link each name back to the client it was picked
* from, and with it that signatory's own PAN and DIN verification.
*/
@Column(name = "authorized_signatory_client_ids", length = 500, nullable = true)
private String authorizedSignatoryClientIds;
@Column(name = "additional_info", length = 500, nullable = true) @Column(name = "additional_info", length = 500, nullable = true)
private String additionalInfo; private String additionalInfo;
@@ -99,9 +107,9 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo, String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
LocalDate dateOfRegistration) { LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
additionalInfo, dateOfRegistration); additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
} }
protected ClientNonPerson() { protected ClientNonPerson() {
@@ -110,7 +118,7 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber, private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo, final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
final LocalDate dateOfRegistration) { final LocalDate dateOfRegistration, final String authorizedSignatoryClientIds) {
if (client != null) { if (client != null) {
this.client = client; this.client = client;
} }
@@ -137,6 +145,10 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
this.authorizedSignatory = authorizedSignatory.trim(); this.authorizedSignatory = authorizedSignatory.trim();
} }
if (StringUtils.isNotBlank(authorizedSignatoryClientIds)) {
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds.trim();
}
if (StringUtils.isNotBlank(additionalInfo)) { if (StringUtils.isNotBlank(additionalInfo)) {
this.additionalInfo = additionalInfo.trim(); this.additionalInfo = additionalInfo.trim();
} }
@@ -258,6 +270,13 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null); this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
} }
if (command.isChangeInStringParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName,
this.authorizedSignatoryClientIds)) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName);
actualChanges.put(ClientApiConstants.authorizedSignatoryClientIdsParamName, newValue);
this.authorizedSignatoryClientIds = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) { if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName); final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput); actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
@@ -48,7 +48,10 @@ public class KycVerificationResult {
.build(); .build();
} }
/** Convenience factory for a failed verification. */ /**
* The provider answered and rejected the document — an unknown CIN, a name that does not match.
* A data problem the officer can act on, not an outage.
*/
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) { public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
return KycVerificationResult.builder() return KycVerificationResult.builder()
.status(Status.FAILURE) .status(Status.FAILURE)
@@ -57,7 +60,25 @@ public class KycVerificationResult {
.build(); .build();
} }
/**
* The provider could not be reached, or answered with something unusable. Nothing was learnt
* about the document, so callers should report this as the service being unavailable rather
* than as the document having failed its check.
*/
public static KycVerificationResult error(final String errorMessage, final String rawResponse) {
return KycVerificationResult.builder()
.status(Status.ERROR)
.errorMessage(errorMessage)
.rawResponse(rawResponse)
.build();
}
public boolean isSuccess() { public boolean isSuccess() {
return Status.SUCCESS == this.status; return Status.SUCCESS == this.status;
} }
/** Distinguishes an unreachable provider from one that answered with a rejection. */
public boolean isServiceError() {
return Status.ERROR == this.status;
}
} }
@@ -322,7 +322,9 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
final JsonObject response = parseJson(rawResponse); final JsonObject response = parseJson(rawResponse);
if (response == null) { if (response == null) {
return KycVerificationResult.failure( // Unparseable body: nothing was learnt about the CIN, so this is the provider
// misbehaving rather than the CIN being bad.
return KycVerificationResult.error(
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse), "CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
rawResponse); rawResponse);
} }
@@ -338,12 +340,12 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
} catch (final HttpClientErrorException | HttpServerErrorException e) { } catch (final HttpClientErrorException | HttpServerErrorException e) {
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber, LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
e.getStatusCode(), e.getResponseBodyAsString()); e.getStatusCode(), e.getResponseBodyAsString());
return KycVerificationResult.failure( return KycVerificationResult.error(
"CIN verification failed (HTTP " + e.getStatusCode() + "): " "CIN verification failed (HTTP " + e.getStatusCode() + "): "
+ e.getResponseBodyAsString(), null); + e.getResponseBodyAsString(), null);
} catch (final Exception e) { } catch (final Exception e) {
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e); LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
return KycVerificationResult.failure("CIN verification failed: " + e.getMessage(), null); return KycVerificationResult.error("CIN verification failed: " + e.getMessage(), null);
} }
} }
@@ -107,6 +107,11 @@ public interface ClientMapper {
@Mapping(target = "legalFormId", ignore = true) @Mapping(target = "legalFormId", ignore = true)
@Mapping(target = "clientCollateralManagements", ignore = true) @Mapping(target = "clientCollateralManagements", ignore = true)
@Mapping(target = "groups", ignore = true) @Mapping(target = "groups", ignore = true)
// Resolved from the latest DIP/MNRL lookup after mapping, not from a Client column — see
// ClientReadPlatformServiceImpl#retrieveOne.
@Mapping(target = "dipLiveStatus", ignore = true)
@Mapping(target = "alternateDipLiveStatus", ignore = true)
@Mapping(target = "sanctionsAlertStatus", ignore = true)
ClientData map(Client source); ClientData map(Client source);
@Named("clientTypeCode") @Named("clientTypeCode")
@@ -107,6 +107,9 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.cinVerified(kyc.isCinVerified()) .cinVerified(kyc.isCinVerified())
.cinVerifiedOn(kyc.getCinVerifiedOn()) .cinVerifiedOn(kyc.getCinVerifiedOn())
.incorporationDate(kyc.getIncorporationDate()) .incorporationDate(kyc.getIncorporationDate())
.dinNumber(kyc.getDinNumber())
.dinVerified(kyc.isDinVerified())
.dinVerifiedOn(kyc.getDinVerifiedOn())
.kycDocumentName(kyc.getKycDocumentName()) .kycDocumentName(kyc.getKycDocumentName())
.kycDocumentNumber(kyc.getKycDocumentNumber()) .kycDocumentNumber(kyc.getKycDocumentNumber())
.kycDocIssueDate(kyc.getKycDocIssueDate()) .kycDocIssueDate(kyc.getKycDocIssueDate())
@@ -93,9 +93,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor())); kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients // CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
final Integer kycTypeOnInitiate = command.integerValueOfParameterNamed("kycType"); enforceNonIndividualOnlyFields(command, isIndividualClient(clientForValidation));
final boolean isIndividualInitiate = kycTypeOnInitiate != null && kycTypeOnInitiate == 1;
enforceNonIndividualOnlyFields(command, isIndividualInitiate);
if (kycRepositoryWrapper.existsByClientId(clientId)) { if (kycRepositoryWrapper.existsByClientId(clientId)) {
LOG.info("KYC record already exists for client {}. Returning existing record.", clientId); LOG.info("KYC record already exists for client {}. Returning existing record.", clientId);
@@ -140,23 +138,29 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId); final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId);
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients // CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual(); enforceNonIndividualOnlyFields(command, isIndividualClient(client));
enforceNonIndividualOnlyFields(command, isIndividualExisting);
final ClientKycStatus currentStatus = kyc.getKycStatus(); final ClientKycStatus currentStatus = kyc.getKycStatus();
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) final Map<String, Object> changes = kyc.update(command);
if (currentStatus.isVerified()) {
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
// request that replaces data the record already held. A non-individual KYC auto-verifies as
// soon as PAN and GSTIN pass, which lands part-way through the create wizard, and the
// officer still has to save the KYC document details afterwards; filling in fields that were
// empty is completing the record, not altering it, and must not demand a justification.
// Thrown before the save below, and this method is @Transactional, so the in-memory
// mutations from update() are rolled back.
if (currentStatus.isVerified() && !kyc.getFieldsOverwrittenByLastUpdate().isEmpty()) {
final String remarks = command.stringValueOfParameterNamed("remarks"); final String remarks = command.stringValueOfParameterNamed("remarks");
if (StringUtils.isBlank(remarks)) { if (StringUtils.isBlank(remarks)) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError( throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"error.msg.client.kyc.remarks.required.for.reverification", "error.msg.client.kyc.remarks.required.for.reverification",
"Remarks are mandatory when updating a verified KYC record.", "remarks"))); "Remarks are mandatory when changing data already recorded on a verified KYC record.",
"remarks")));
} }
} }
final Map<String, Object> changes = kyc.update(command);
if (!changes.isEmpty()) { if (!changes.isEmpty()) {
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
} }
@@ -335,7 +339,11 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
// firstname/middlename/lastname ← individual columns; NULL for entities // firstname/middlename/lastname ← individual columns; NULL for entities
// fullname ← the entity name lives here // fullname ← the entity name lives here
// dateOfBirth ← only set for individuals // dateOfBirth ← only set for individuals
// ClientNonPerson.dateOfRegistration ← the incorporation date for entities // ClientKyc.incorporationDate ← the incorporation date for entities, captured on the
// KYC step next to the entity PAN it is verified against
// ClientNonPerson.dateOfRegistration ← where that date lived for clients created before
// the field moved to the KYC step; read as a fallback
// so those older entities still verify.
// LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format" // LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format"
// response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the // response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the
// right (name, dob) pair per legal form so we always send LincPay the data it expects. // right (name, dob) pair per legal form so we always send LincPay the data it expects.
@@ -343,10 +351,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final String dob; final String dob;
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) { if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : ""; clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId()); java.time.LocalDate incorpDate = kyc.getIncorporationDate();
final java.time.LocalDate regDate = (np != null) ? np.getDateOfRegistration() : null; if (incorpDate == null) {
dob = regDate != null final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
? regDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")) incorpDate = (np != null) ? np.getDateOfRegistration() : null;
}
dob = incorpDate != null
? incorpDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
: null; : null;
} else { } else {
dob = client.dateOfBirth() != null dob = client.dateOfBirth() != null
@@ -521,15 +532,36 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
"CIN number is not set on this KYC record. Update the KYC record first."); "CIN number is not set on this KYC record. Update the KYC record first.");
} }
final KycVerificationResult result = kycVerificationService.verifyCin(kyc.getCinNumber()); final String cin = kyc.getCinNumber().toUpperCase().trim();
if (!cin.matches(ClientKycApiConstants.CIN_REGEX)) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"error.msg.client.kyc.cin.invalid.format",
"Invalid CIN format. Expected 21 characters, e.g. L17110MH1973PLC019786.",
ClientKycApiConstants.cinNumberParam)));
}
if (!result.isSuccess()) { final KycVerificationResult result = kycVerificationService.verifyCin(cin);
if (!result.isSuccess() && result.isServiceError()) {
throw new PlatformServiceUnavailableException( throw new PlatformServiceUnavailableException(
"error.msg.client.kyc.cin.verification.failed", "error.msg.client.kyc.cin.verification.failed",
"CIN verification failed: " + result.getErrorMessage(), "CIN verification failed: " + result.getErrorMessage(),
result.getErrorMessage()); result.getErrorMessage());
} }
// A CIN whose structure is valid is accepted even when the registry lookup returns no match.
// The provider's company endpoint answers "Not Found" for every CIN put to it, including
// ones that are demonstrably real, so a negative from it says nothing about the company and
// cannot be allowed to block onboarding. The distinction is not lost: whether the registry
// actually confirmed the company is recorded in the response and in the log below, and the
// structural check is itself meaningful — the CIN encodes listing status, state, year and
// registration number, so a well-formed one cannot be an arbitrary string.
final boolean confirmedByRegistry = result.isSuccess();
if (!confirmedByRegistry) {
LOG.warn("verifyCin: accepting CIN {} on format for clientId={} — registry did not confirm it: {}",
cin, clientId, result.getErrorMessage());
}
kyc.markCinVerified(); kyc.markCinVerified();
kyc.tryAutoVerify(); kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc); kycRepositoryWrapper.saveAndFlush(kyc);
@@ -537,6 +569,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final Map<String, Object> changes = new java.util.LinkedHashMap<>(); final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("cinVerified", true); changes.put("cinVerified", true);
changes.put("cinVerifiedOn", kyc.getCinVerifiedOn()); changes.put("cinVerifiedOn", kyc.getCinVerifiedOn());
changes.put("cinConfirmedByRegistry", confirmedByRegistry);
if (!confirmedByRegistry) {
changes.put("cinVerificationMessage", "CIN format validated. MCA records could not confirm this company.");
}
changes.put("kycStatus", kyc.getKycStatusValue()); changes.put("kycStatus", kyc.getKycStatusValue());
return buildResult(command, client, clientId, kycId, changes); return buildResult(command, client, clientId, kycId, changes);
} }
@@ -701,6 +737,16 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
return s.trim().toLowerCase().replaceAll("[^a-z0-9]", ""); return s.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
} }
/**
* Whether the client itself is a natural person. Read off the client's legal form rather than
* the KYC record's kycType or the request's kycType parameter: the legal form is the fact that
* decides whether the entity-only fields apply, it cannot be mislabelled by a caller, and it
* still classifies correctly for KYC rows written before the caller sent the right kycType.
*/
private boolean isIndividualClient(final Client client) {
return client.getLegalForm() == null || !LegalForm.fromInt(client.getLegalForm()).isEntity();
}
/** /**
* CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful * CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful
* for NON_INDIVIDUAL clients. Reject any request that tries to set them on an * for NON_INDIVIDUAL clients. Reject any request that tries to set them on an
@@ -976,11 +1022,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId()); final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC. // Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson); final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper, final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null); "CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
enforceNonIndividualOnlyFields(jsonCommand, isIndividualExisting); enforceNonIndividualOnlyFields(jsonCommand, isIndividualClient(client));
final ClientKycStatus currentStatus = kyc.getKycStatus(); final ClientKycStatus currentStatus = kyc.getKycStatus();
final Map<String, Object> changes = kyc.update(jsonCommand); final Map<String, Object> changes = kyc.update(jsonCommand);
@@ -79,10 +79,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
private final ClientLookupMapper lookupMapper = new ClientLookupMapper(); private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper(); private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper(); private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
private final ClientNonPersonDataMapper clientNonPersonMapper = new ClientNonPersonDataMapper();
private final ColumnValidator columnValidator; private final ColumnValidator columnValidator;
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper; private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
private final ClientRepositoryWrapper clientRepositoryWrapper; private final ClientRepositoryWrapper clientRepositoryWrapper;
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
private final ClientMapper clientMapper; private final ClientMapper clientMapper;
@Override @Override
@@ -227,6 +230,24 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString); final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
final ClientData clientData = clientMapper.map(client); final ClientData clientData = clientMapper.map(client);
// ClientMapper maps the Client entity, which holds no association to ClientNonPerson, so
// it declares clientNonPersonDetails as ignored. Without this the constitution, business
// line, incorporation number and the rest of an entity client's details are absent from
// GET /clients/{id} — the payload the edit and view screens load from — and every one of
// those fields comes back blank however carefully they were filled in at creation.
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
// dipVerified is the flag captured when the client was onboarded and never changes
// afterwards. Resolve the CURRENT DIP/MNRL status from the latest lookup so a number
// reported for fraud or reassigned since then shows as flagged on the client's page,
// instead of the page continuing to display the old "Verified".
clientData.setDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getMobileNo()));
clientData.setAlternateDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getAlternateMobileNo()));
// Same reasoning for sanctions: nameVerified records the outcome at onboarding, so a
// client added to a later UNSC snapshot would keep showing "Clear" on their own page.
clientData.setSanctionsAlertStatus(this.sanctionsScreeningService.getLatestAlertStatus(clientId));
// Get client collaterals // Get client collaterals
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
.getCollateralsPerClient(clientId); .getCollateralsPerClient(clientId);
@@ -254,6 +275,57 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
} }
} }
/**
* The entity-only block of a client, or null for an individual (or an entity whose details row
* was never written). Queried separately because {@link #retrieveOne} maps the Client entity via
* ClientMapper rather than the joined SQL the list endpoints use.
*/
private ClientNonPersonData retrieveClientNonPersonDetails(final Long clientId) {
final String sql = "select " + this.clientNonPersonMapper.schema();
final List<ClientNonPersonData> details = this.jdbcTemplate.query(sql, this.clientNonPersonMapper, // NOSONAR
clientId);
return details.isEmpty() ? null : details.get(0);
}
private static final class ClientNonPersonDataMapper implements RowMapper<ClientNonPersonData> {
public String schema() {
return " cnp.constitution_cv_id as constitutionId, cvConstitution.code_value as constitutionValue,"
+ " cnp.incorp_no as incorpNo, cnp.incorp_validity_till as incorpValidityTill,"
+ " cnp.main_business_line_cv_id as mainBusinessLineId,"
+ " cvMainBusinessLine.code_value as mainBusinessLineValue, cnp.remarks as remarks,"
+ " cnp.authorized_signatory as authorizedSignatory,"
+ " cnp.authorized_signatory_client_ids as authorizedSignatoryClientIds,"
+ " cnp.additional_info as additionalInfo,"
+ " cnp.date_of_registration as dateOfRegistration" + " from m_client_non_person cnp"
+ " left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id"
+ " left join m_code_value cvMainBusinessLine on cvMainBusinessLine.id = cnp.main_business_line_cv_id"
+ " where cnp.client_id = ?";
}
@Override
public ClientNonPersonData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long constitutionId = JdbcSupport.getLong(rs, "constitutionId");
final String constitutionValue = rs.getString("constitutionValue");
final CodeValueData constitution = CodeValueData.instance(constitutionId, constitutionValue);
final Long mainBusinessLineId = JdbcSupport.getLong(rs, "mainBusinessLineId");
final String mainBusinessLineValue = rs.getString("mainBusinessLineValue");
final CodeValueData mainBusinessLine = CodeValueData.instance(mainBusinessLineId, mainBusinessLineValue);
final String incorpNo = rs.getString("incorpNo");
final LocalDate incorpValidityTill = JdbcSupport.getLocalDate(rs, "incorpValidityTill");
final String remarks = rs.getString("remarks");
final String authorizedSignatory = rs.getString("authorizedSignatory");
final String authorizedSignatoryClientIds = rs.getString("authorizedSignatoryClientIds");
final String additionalInfo = rs.getString("additionalInfo");
final LocalDate dateOfRegistration = JdbcSupport.getLocalDate(rs, "dateOfRegistration");
return new ClientNonPersonData(constitution, incorpNo, incorpValidityTill, mainBusinessLine, remarks,
authorizedSignatory, additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
}
}
@Override @Override
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) { public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
@@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler; import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -134,6 +135,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final ClientRepository clientJpaRepository; private final ClientRepository clientJpaRepository;
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository; private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository; private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
private final CifNumberGenerator cifNumberGenerator; private final CifNumberGenerator cifNumberGenerator;
private final ClientContactOtpService clientContactOtpService; private final ClientContactOtpService clientContactOtpService;
private final ClientChangeNotificationService clientChangeNotificationService; private final ClientChangeNotificationService clientChangeNotificationService;
@@ -156,6 +159,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
this.clientNonPersonRepository.delete(clientNonPerson); this.clientNonPersonRepository.delete(clientNonPerson);
} }
// Safely cascade-delete child records that have foreign keys pointing to this client
this.jdbcTemplate.update("delete from m_client_identifier where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_family_members where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_client_address where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_address where client_id = ?", clientId);
this.jdbcTemplate.update("delete from m_client_kyc where client_id = ?", clientId);
this.clientRepository.delete(client); this.clientRepository.delete(client);
this.clientRepository.flush(); this.clientRepository.flush();
return new CommandProcessingResultBuilder() // return new CommandProcessingResultBuilder() //
@@ -611,6 +621,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
newClient.setScreeningLogId(screeningLogId); newClient.setScreeningLogId(screeningLogId);
} }
// UNSC sanctions gate. The UI disables Save & Next on a match, but that is a browser
// control only — screen server-side too, so a direct API call cannot onboard a listed
// person. The name is cleared only by a false positive the Principal Officer approved.
validateNotSanctioned(newClient.getDisplayName());
// DIP/MNRL gate — same reasoning: dipVerified arrives from the browser and cannot be
// trusted on its own. Record the status actually on file alongside it.
validateMobileNumberUsable(newClient.getMobileNo(), "mobileNo");
validateMobileNumberUsable(newClient.getAlternateMobileNo(), "alternateMobileNo");
newClient.setDipStatus(this.mobileLookupService.getLatestStatus(newClient.getMobileNo()));
newClient.setAlternateDipStatus(this.mobileLookupService.getLatestStatus(newClient.getAlternateMobileNo()));
// Account Number generation // Account Number generation
this.clientRepository.saveAndFlush(newClient); this.clientRepository.saveAndFlush(newClient);
if (StringUtils.isBlank(accountNo)) { if (StringUtils.isBlank(accountNo)) {
@@ -756,6 +778,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final String authorizedSignatory = this.fromApiJsonHelper final String authorizedSignatory = this.fromApiJsonHelper
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement); .extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
final String authorizedSignatoryClientIds = this.fromApiJsonHelper
.extractStringNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName, clientNonPersonElement);
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName, final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
clientNonPersonElement); clientNonPersonElement);
final LocalDate dateOfRegistration = this.fromApiJsonHelper final LocalDate dateOfRegistration = this.fromApiJsonHelper
@@ -763,7 +787,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution, final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo, clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
dateOfRegistration); dateOfRegistration, authorizedSignatoryClientIds);
this.clientNonPersonRepository.save(newClientNonPerson); this.clientNonPersonRepository.save(newClientNonPerson);
} }
@@ -884,12 +908,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
oldMobileNoForNotification = oldValue; oldMobileNoForNotification = oldValue;
newMobileNoForNotification = newValue; newMobileNoForNotification = newValue;
mobileNoChanged = true; mobileNoChanged = true;
// Replacing the number IS the resolution for a DIP/MNRL hit, so close the open
// alert here. Left to the officer as a second manual step it would be forgotten,
// and the queue would fill with cases that were in fact already dealt with.
closeDipAlertForReplacedNumber(oldValue, newValue, clientId);
} }
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) { if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName); final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
final String oldAlternate = clientForUpdate.getAlternateMobileNo();
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue); changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null)); clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
closeDipAlertForReplacedNumber(oldAlternate, newValue, clientId);
} }
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) { if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
@@ -1700,6 +1730,82 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
/* /*
* To become a part of a group, group may have set of criteria to be m et before client can become member of it. * To become a part of a group, group may have set of criteria to be m et before client can become member of it.
*/ */
/**
* Refuses onboarding of a name that hits the UN Security Council consolidated list.
*
* <p>
* Applies equally to individuals and non-individuals — the display name covers both, and the
* list itself holds INDIVIDUAL and ENTITY records. The only way past this is a false positive
* on the same name that the Principal Officer has approved, which is deliberately a different
* person's decision from the officer who reviewed the hit.
* </p>
*/
private void validateNotSanctioned(final String displayName) {
if (StringUtils.isBlank(displayName)) {
return;
}
if (this.sanctionsScreeningService.hasApprovedFalsePositive(displayName)) {
return;
}
// Non-logging check on purpose — see SanctionsScreeningService#isNameSanctioned.
if (this.sanctionsScreeningService.isNameSanctioned(displayName)) {
throw new GeneralPlatformDomainRuleException("error.msg.client.sanctions.match",
"'" + displayName + "' matched the UN Security Council Consolidated List. Onboarding is blocked until the "
+ "Principal Officer clears the alert.",
displayName);
}
}
/**
* Refuses a mobile number that the DoT Digital Intelligence Platform flagged, unless an
* officer explicitly recorded a decision to proceed with a written reason.
*
* <p>
* A number with no lookup on record passes: DIP verification is enforced in the onboarding
* flow, and failing here would block every legacy update that merely touches an old client.
* What this stops is the case that matters — a number the DoT has actively flagged being
* saved because the browser said it was fine.
* </p>
*/
/**
* Closes any open DIP/MNRL alert on a number that has just been replaced.
*
* <p>
* For an existing customer a flagged number is resolved by obtaining a new one — at which
* point the old alert describes a number the bank no longer uses. Closing it here keeps the
* decision queue a list of work actually outstanding rather than a growing pile of cases that
* were already handled. Failure is logged, never propagated: the client update itself must
* not fail because a bookkeeping row could not be stamped.
* </p>
*/
private void closeDipAlertForReplacedNumber(final String oldNumber, final String newNumber, final Long clientId) {
if (StringUtils.isBlank(oldNumber) || StringUtils.equals(oldNumber, newNumber)) {
return;
}
try {
final String reason = StringUtils.isBlank(newNumber)
? "Number removed from the client record on resolution of the DIP/MNRL alert."
: "Number replaced with " + newNumber + " on resolution of the DIP/MNRL alert.";
this.mobileLookupService.closeOpenAlerts(oldNumber.trim(), clientId, reason);
} catch (Exception e) {
log.error("Could not close DIP alert for replaced number {} on client {}: {}", oldNumber, clientId,
e.getMessage());
}
}
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
if (StringUtils.isBlank(mobileNo)) {
return;
}
if (this.mobileLookupService.isMobileNumberUsable(mobileNo.trim())) {
return;
}
throw new GeneralPlatformDomainRuleException("error.msg.client.mobile.dip.flagged",
"Mobile number " + mobileNo + " is flagged on the DoT Digital Intelligence Platform. Record a decision "
+ "to proceed, with reasons, before saving this client.",
parameter);
}
private void validateParentGroupRulesBeforeClientActivation(Client client) { private void validateParentGroupRulesBeforeClientActivation(Client client) {
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup(); Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup(); Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
@@ -0,0 +1,102 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.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 java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/batchruns")
@Component
@Tag(name = "DDS Batch Runs", description = "The Interest/Commission calculate-then-post batch: Generate previews a run's rows without changing any balance; Post applies exactly those previewed rows. A run can only be posted once.")
@RequiredArgsConstructor
public class BatchRunsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSBATCH";
private final PlatformSecurityContext context;
private final BatchRunReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Batch Runs", description = "Returns the list of DDS batch runs, most recent first.")
public Collection<BatchRunData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{batchRunId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Batch Run", description = "Returns the details of a batch run, including its previewed/posted rows.")
public BatchRunData retrieveOne(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(batchRunId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Generate a DDS Batch Run", description = "Mandatory fields: jobType (INTEREST_CALCULATION, "
+ "OD_INTEREST_CALCULATION or COMMISSION_CALCULATION), branchScope (All or Selected), jobDate, agentFrom, agentTo\n"
+ "\n" + "Optional fields: partyFrom, partyTo (interest jobs only)")
public CommandProcessingResult generate(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.generateDdsBatchRun() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@POST
@Path("{batchRunId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post a DDS Batch Run", description = "Applies exactly the rows a prior Generate call previewed: credits "
+ "each row's amount to the Party (interest jobs) or to the Agent's running commission total (commission jobs). "
+ "Fails if the run has already been posted.")
public CommandProcessingResult post(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.postDdsBatchRun(batchRunId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -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.dds.batch.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BatchRunRepository extends JpaRepository<BatchRun, Long> {
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "DDSBATCH", action = "GENERATE")
public class GenerateBatchRunCommandHandler implements NewCommandSourceHandler {
private final BatchRunWritePlatformService writePlatformService;
@Autowired
public GenerateBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.generateBatchRun(command);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "DDSBATCH", action = "POST")
public class PostBatchRunCommandHandler implements NewCommandSourceHandler {
private final BatchRunWritePlatformService writePlatformService;
@Autowired
public PostBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.postBatchRun(command.entityId(), command);
}
}
@@ -0,0 +1,106 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.serialization;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class BatchRunCommandFromApiJsonDeserializer {
public static final Set<String> SUPPORTED_JOB_TYPES = new HashSet<>(
Arrays.asList("INTEREST_CALCULATION", "OD_INTEREST_CALCULATION", "COMMISSION_CALCULATION"));
private static final Set<String> SUPPORTED_BRANCH_SCOPES = new HashSet<>(Arrays.asList("All", "Selected"));
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("jobType", "branchScope", "officeNames",
"jobDate", "agentFrom", "agentTo", "partyFrom", "partyTo", "dateFormat", "locale"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public BatchRunCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForGenerate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsbatchrun");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("jobType").value(this.fromApiJsonHelper.extractStringNamed("jobType", element)).notBlank()
.isOneOfTheseStringValues(SUPPORTED_JOB_TYPES.toArray());
final String branchScope = this.fromApiJsonHelper.extractStringNamed("branchScope", element);
v.reset().parameter("branchScope").value(branchScope).notBlank().isOneOfTheseStringValues(SUPPORTED_BRANCH_SCOPES.toArray());
if ("Selected".equals(branchScope)) {
v.reset().parameter("officeNames").value(this.fromApiJsonHelper.extractJsonArrayNamed("officeNames", element))
.jsonArrayNotEmpty();
}
v.reset().parameter("jobDate").value(this.fromApiJsonHelper.extractLocalDateNamed("jobDate", element)).notNull();
v.reset().parameter("agentFrom").value(this.fromApiJsonHelper.extractLongNamed("agentFrom", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("agentTo").value(this.fromApiJsonHelper.extractLongNamed("agentTo", element)).notNull()
.integerGreaterThanZero();
if (this.fromApiJsonHelper.parameterExists("partyFrom", element)) {
v.reset().parameter("partyFrom").value(this.fromApiJsonHelper.extractLongNamed("partyFrom", element)).ignoreIfNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("partyTo", element)) {
v.reset().parameter("partyTo").value(this.fromApiJsonHelper.extractLongNamed("partyTo", element)).ignoreIfNull()
.integerGreaterThanZero();
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -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.dds.batch.service;
import java.util.Collection;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
public interface BatchRunReadPlatformService {
Collection<BatchRunData> retrieveAll();
BatchRunData retrieveOne(Long batchRunId);
}
@@ -0,0 +1,137 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunRowData;
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final BatchRunMapper BATCH_RUN_MAPPER = new BatchRunMapper();
private static final BatchRunRowMapper ROW_MAPPER = new BatchRunRowMapper();
private static final class BatchRunMapper implements RowMapper<BatchRunData> {
public String schema() {
return " b.id as id, b.job_type as jobType, b.branch_scope as branchScope, b.job_date as jobDate, "
+ " b.agent_from as agentFrom, b.agent_to as agentTo, b.party_from as partyFrom, "
+ " b.party_to as partyTo, b.status as status, b.total_rows as totalRows, b.total_amount as totalAmount, "
+ " b.generated_on_date as generatedOnDate, b.posted_on_date as postedOnDate from m_dds_batch_run b";
}
@Override
public BatchRunData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return BatchRunData.builder() //
.id(rs.getLong("id")) //
.jobType(rs.getString("jobType")) //
.branchScope(rs.getString("branchScope")) //
.jobDate(JdbcSupport.getLocalDate(rs, "jobDate")) //
.agentFrom(rs.getLong("agentFrom")) //
.agentTo(rs.getLong("agentTo")) //
.partyFrom(JdbcSupport.getLong(rs, "partyFrom")) //
.partyTo(JdbcSupport.getLong(rs, "partyTo")) //
.status(rs.getString("status")) //
.totalRows(rs.getInt("totalRows")) //
.totalAmount(rs.getBigDecimal("totalAmount")) //
.generatedOnDate(JdbcSupport.getLocalDate(rs, "generatedOnDate")) //
.postedOnDate(JdbcSupport.getLocalDate(rs, "postedOnDate")) //
.rows(new ArrayList<>()) //
.build();
}
}
private static final class BatchRunRowMapper implements RowMapper<Map.Entry<Long, BatchRunRowData>> {
public String schema() {
return " r.batch_run_id as batchRunId, r.id as id, r.party_id as partyId, p.party_code as partyCode, "
+ " p.party_name as partyName, r.agent_id as agentId, a.agent_code as agentCode, "
+ " r.amount as amount from m_dds_batch_run_row r " + " left join m_dds_party p on p.id = r.party_id "
+ " join m_dds_agent a on a.id = r.agent_id";
}
@Override
public Map.Entry<Long, BatchRunRowData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long batchRunId = rs.getLong("batchRunId");
final BatchRunRowData data = BatchRunRowData.builder() //
.id(rs.getLong("id")) //
.partyId(JdbcSupport.getLong(rs, "partyId")) //
.partyCode(rs.getString("partyCode")) //
.partyName(rs.getString("partyName")) //
.agentId(rs.getLong("agentId")) //
.agentCode(rs.getString("agentCode")) //
.amount(rs.getBigDecimal("amount")) //
.build();
return Map.entry(batchRunId, data);
}
}
@Override
public Collection<BatchRunData> retrieveAll() {
final String sql = "select " + BATCH_RUN_MAPPER.schema() + " order by b.id desc";
final List<BatchRunData> batchRuns = this.jdbcTemplate.query(sql, BATCH_RUN_MAPPER); // NOSONAR
return attachRows(batchRuns);
}
@Override
public BatchRunData retrieveOne(final Long batchRunId) {
try {
final String sql = "select " + BATCH_RUN_MAPPER.schema() + " where b.id = ?";
final BatchRunData batchRun = this.jdbcTemplate.queryForObject(sql, BATCH_RUN_MAPPER, batchRunId); // NOSONAR
return attachRows(List.of(batchRun)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new BatchRunNotFoundException(batchRunId);
}
}
private Collection<BatchRunData> attachRows(final List<BatchRunData> batchRuns) {
if (batchRuns.isEmpty()) {
return batchRuns;
}
final String sql = "select " + ROW_MAPPER.schema() + " order by r.id";
final List<Map.Entry<Long, BatchRunRowData>> rows = this.jdbcTemplate.query(sql, ROW_MAPPER); // NOSONAR
final Map<Long, List<BatchRunRowData>> rowsByBatchRunId = new LinkedHashMap<>();
for (final Map.Entry<Long, BatchRunRowData> row : rows) {
rowsByBatchRunId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<BatchRunData> result = new ArrayList<>(batchRuns.size());
for (final BatchRunData b : batchRuns) {
result.add(b.toBuilder().rows(rowsByBatchRunId.getOrDefault(b.getId(), new ArrayList<>())).build());
}
return result;
}
}
@@ -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.dds.batch.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface BatchRunWritePlatformService {
CommandProcessingResult generateBatchRun(JsonCommand command);
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
}
@@ -0,0 +1,227 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import com.google.gson.JsonArray;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
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.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRun;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRow;
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunAlreadyPostedException;
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException;
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365);
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private final BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final BatchRunRepository batchRunRepository;
private final PartyRepository partyRepository;
private final AgentRepository agentRepository;
private final PartyTransactionRepository partyTransactionRepository;
private final CommissionSlabRepository commissionSlabRepository;
@Transactional
@Override
public CommandProcessingResult generateBatchRun(final JsonCommand command) {
this.fromApiJsonDeserializer.validateForGenerate(command.json());
final String jobType = command.stringValueOfParameterNamed("jobType");
final String branchScope = command.stringValueOfParameterNamed("branchScope");
final LocalDate jobDate = command.localDateValueOfParameterNamed("jobDate");
final Long agentFrom = command.longValueOfParameterNamed("agentFrom");
final Long agentTo = command.longValueOfParameterNamed("agentTo");
final Long partyFrom = command.parameterExists("partyFrom") ? command.longValueOfParameterNamed("partyFrom") : 1L;
final Long partyTo = command.parameterExists("partyTo") ? command.longValueOfParameterNamed("partyTo") : Long.MAX_VALUE;
final Set<String> officeNames = "Selected".equals(branchScope) ? extractOfficeNames(command) : null;
final BatchRun batchRun = new BatchRun(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, LocalDate.now());
switch (jobType) {
case "INTEREST_CALCULATION" -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
officeNames);
case "OD_INTEREST_CALCULATION" -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
officeNames);
case "COMMISSION_CALCULATION" -> generateCommissionRows(batchRun, agentFrom, agentTo, jobDate, officeNames);
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
}
this.batchRunRepository.saveAndFlush(batchRun);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(batchRun.getId()) //
.build();
}
/** Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched
* against Agent.homeBranch by name, since that field is plain text (not a real Office FK)
* mirroring the same free-text branch concept the legacy screen itself used. */
private Set<String> extractOfficeNames(final JsonCommand command) {
if (!command.parameterExists("officeNames")) {
return null;
}
final JsonArray officeNamesArray = command.arrayOfParameterNamed("officeNames");
final Set<String> officeNames = new HashSet<>();
for (int i = 0; i < officeNamesArray.size(); i++) {
officeNames.add(officeNamesArray.get(i).getAsString().trim().toUpperCase());
}
return officeNames;
}
private boolean matchesSelectedBranch(final String homeBranch, final Set<String> officeNames) {
if (officeNames == null || officeNames.isEmpty()) {
return true;
}
return homeBranch != null && officeNames.contains(homeBranch.trim().toUpperCase());
}
private void generateInterestRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
final Long partyTo, final LocalDate jobDate, final boolean odOnly, final Set<String> officeNames) {
final List<Party> parties = odOnly
? this.partyRepository.findForOdInterestBatch(agentFrom, agentTo, partyFrom, partyTo)
: this.partyRepository.findForInterestBatch(agentFrom, agentTo, partyFrom, partyTo);
for (final Party party : parties) {
if (!matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) {
continue;
}
final BigDecimal rate = party.getInterestRate();
final BigDecimal balance = party.getCurrentBalance();
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0 || balance.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
final LocalDate accrueFrom = party.getInterestDate() != null ? party.getInterestDate() : party.getOpeningDate();
final long days = ChronoUnit.DAYS.between(accrueFrom, jobDate);
if (days <= 0) {
continue;
}
final BigDecimal amount = balance.multiply(rate).multiply(BigDecimal.valueOf(days))
.divide(HUNDRED.multiply(DAYS_IN_YEAR), 6, RoundingMode.HALF_UP).setScale(2, RoundingMode.HALF_UP);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
batchRun.addRow(new BatchRunRow(party.getId(), party.getAgent().getId(), amount));
}
}
private void generateCommissionRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final LocalDate jobDate,
final Set<String> officeNames) {
final List<Agent> agents = this.agentRepository.findBetweenIds(agentFrom, agentTo);
for (final Agent agent : agents) {
if (!matchesSelectedBranch(agent.getHomeBranch(), officeNames)) {
continue;
}
final LocalDate fromDate = agent.getLastCommissionDate() != null ? agent.getLastCommissionDate().plusDays(1)
: agent.getAgentOpeningDate();
if (fromDate.isAfter(jobDate)) {
continue;
}
final BigDecimal collectionVolume = this.partyTransactionRepository.sumPartyTranCredits(agent.getId(), fromDate, jobDate);
if (collectionVolume == null || collectionVolume.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
final List<CommissionSlab> matchingSlabs = this.commissionSlabRepository.findMatchingSlabs("ACCOUNT", collectionVolume);
if (matchingSlabs.isEmpty()) {
continue;
}
final BigDecimal commissionPercent = matchingSlabs.get(0).getCommissionPercent();
final BigDecimal amount = collectionVolume.multiply(commissionPercent).divide(HUNDRED, 2, RoundingMode.HALF_UP);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
batchRun.addRow(new BatchRunRow(null, agent.getId(), amount));
}
}
@Transactional
@Override
public CommandProcessingResult postBatchRun(final Long batchRunId, final JsonCommand command) {
final BatchRun batchRun = this.batchRunRepository.findById(batchRunId).orElseThrow(() -> new BatchRunNotFoundException(batchRunId));
if (batchRun.isPosted()) {
throw new BatchRunAlreadyPostedException(batchRunId);
}
final boolean isCommissionJob = "COMMISSION_CALCULATION".equals(batchRun.getJobType());
final String transactionType = "OD_INTEREST_CALCULATION".equals(batchRun.getJobType()) ? "OD_INTEREST_POST" : "INTEREST_POST";
for (final BatchRunRow row : batchRun.getRows()) {
if (isCommissionJob) {
final Agent agent = this.agentRepository.findById(row.getAgentId())
.orElseThrow(() -> new AgentNotFoundException(row.getAgentId()));
agent.postCommission(row.getAmount(), batchRun.getJobDate());
} else {
final Party party = this.partyRepository.findById(row.getPartyId())
.orElseThrow(() -> new PartyNotFoundException(row.getPartyId()));
final Agent agent = party.getAgent();
final BigDecimal newBalance = party.credit(row.getAmount());
party.markInterestCalculatedThrough(batchRun.getJobDate());
final PartyTransaction transaction = new PartyTransaction(party, agent, batchRun.getJobDate(), transactionType,
"CREDIT", null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance);
this.partyTransactionRepository.save(transaction);
}
}
batchRun.markPosted(LocalDate.now());
this.batchRunRepository.saveAndFlush(batchRun);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(batchRunId) //
.build();
}
}
@@ -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.dds.batch.starter;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsBatchConfiguration {
@Bean
@ConditionalOnMissingBean(BatchRunReadPlatformService.class)
public BatchRunReadPlatformService batchRunReadPlatformService(JdbcTemplate jdbcTemplate) {
return new BatchRunReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(BatchRunWritePlatformService.class)
public BatchRunWritePlatformService batchRunWritePlatformService(BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer,
BatchRunRepository batchRunRepository, PartyRepository partyRepository, AgentRepository agentRepository,
PartyTransactionRepository partyTransactionRepository, CommissionSlabRepository commissionSlabRepository) {
return new BatchRunWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, batchRunRepository, partyRepository,
agentRepository, partyTransactionRepository, commissionSlabRepository);
}
}
@@ -0,0 +1,128 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.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.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/parties")
@Component
@Tag(name = "DDS Parties", description = "A DDS Party is a customer's daily-deposit scheme account, linked to the Agent who collects for it. Carries the scheme's KYC, terms, two-stage overdue flags and a nominee list.")
@RequiredArgsConstructor
public class PartiesApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "PARTY";
private final PlatformSecurityContext context;
private final PartyReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Parties", description = "Returns the list of DDS parties.")
public Collection<PartyData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Party", description = "Returns the details of a DDS party, including its nominees.")
public PartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(partyId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a DDS Party", description = "Mandatory fields: clientId, agentId, subType, partyName, address1, "
+ "city, pincode, mobileNoSms, operation, adultMinor, openingDate, dailyDepositAmount, days\n" + "\n"
+ "Optional fields: lastAccount, nominal, aadharNo, partyName1, address2, address3, gender, panCardNo, telephone, "
+ "birthDate, occupation, risk, ckycId, minorDate, guardianClientId, minAccountOpt, relation, month, maturityDate, "
+ "reopenDate, depositAmount, ledgerNo, withdrawType, lien, payDate, paymentAmount, openingBalance, isSmsActive, "
+ "isEmailActive, interestDate, spDescription, freezeCode, advCode, creditToBranch, creditToAccountType, "
+ "creditToAccount, nomineeApplicable, nomineeSplitMode, nominees, savingsAccountNo (links this party to an existing, "
+ "non-closed real savings account of the same client, resolved by account number - omit if the client has none)")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createParty() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@PUT
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a DDS Party", description = "Updates the details of a DDS party.")
public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@POST
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Close a DDS Party", description = "command=close is the only supported command. Mandatory field: "
+ "closeDate. The party's balance must already be zero (settled with a full Withdraw Deposite payout) before it "
+ "can be closed.")
public CommandProcessingResult handleCommand(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@QueryParam("command") @Parameter(description = "command") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (!"close".equals(commandParam)) {
throw new UnrecognizedQueryParamException("command", commandParam);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.closeParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -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.dds.party.domain;
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;
public interface PartyRepository extends JpaRepository<Party, Long> {
@Query("select coalesce(max(cast(substring(p.partyCode, 3) as long)), 0) from Party p where p.partyCode like 'PT%'")
Long retrieveMaxPartyCodeSuffix();
Optional<Party> findByPartyCode(String partyCode);
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo")
List<Party> findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo);
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and (p.od1Flag = true or p.od2Flag = true)")
List<Party> findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo);
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "CLOSE")
public class ClosePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public ClosePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.closeParty(command.entityId(), command);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "CREATE")
public class CreatePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public CreatePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createParty(command);
}
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "UPDATE")
public class UpdatePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public UpdatePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateParty(command.entityId(), command);
}
}
@@ -0,0 +1,294 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class PartyCommandFromApiJsonDeserializer {
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("clientId", "agentId", "subType",
"lastAccount", "nominal", "aadharNo", "partyName", "partyName1", "address1", "address2", "address3", "city", "pincode",
"gender", "panCardNo", "telephone", "mobileNoSms", "operation", "birthDate", "occupation", "risk", "ckycId", "adultMinor",
"minorDate", "guardianClientId", "minAccountOpt", "relation", "openingDate", "month", "maturityDate", "reopenDate",
"depositAmount", "dailyDepositAmount", "days", "ledgerNo", "interestRate", "withdrawType", "lien", "payDate",
"paymentAmount", "openingBalance", "isSmsActive", "isEmailActive", "interestDate", "spDescription", "freezeCode", "advCode",
"creditToBranch", "creditToAccountType", "creditToAccount", "nomineeApplicable", "nomineeSplitMode", "nominees",
"savingsAccountNo", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_NOMINEE_PARAMETERS = new HashSet<>(
Arrays.asList("nominationDate", "name", "mobileNo", "birthDate", "minor", "relation", "gender", "guardianName"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public PartyCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("clientId").value(this.fromApiJsonHelper.extractLongNamed("clientId", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("subType").value(this.fromApiJsonHelper.extractStringNamed("subType", element)).notBlank()
.notExceedingLengthOf(60);
v.reset().parameter("partyName").value(this.fromApiJsonHelper.extractStringNamed("partyName", element)).notBlank()
.notExceedingLengthOf(100);
v.reset().parameter("address1").value(this.fromApiJsonHelper.extractStringNamed("address1", element)).notBlank()
.notExceedingLengthOf(200);
v.reset().parameter("city").value(this.fromApiJsonHelper.extractStringNamed("city", element)).notBlank()
.notExceedingLengthOf(100);
v.reset().parameter("pincode").value(this.fromApiJsonHelper.extractStringNamed("pincode", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("mobileNoSms").value(this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("operation").value(this.fromApiJsonHelper.extractStringNamed("operation", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull();
v.reset().parameter("dailyDepositAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("dailyDepositAmount", element))
.notNull().positiveAmount();
v.reset().parameter("days").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element)).notNull()
.integerGreaterThanZero();
validateOptionalFields(element, v);
if (this.fromApiJsonHelper.parameterExists("nominees", element)) {
validateNominees(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForUpdate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (this.fromApiJsonHelper.parameterExists("clientId", element)) {
v.reset().parameter("clientId").value(this.fromApiJsonHelper.extractLongNamed("clientId", element)).notNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("agentId", element)) {
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("subType", element)) {
v.reset().parameter("subType").value(this.fromApiJsonHelper.extractStringNamed("subType", element)).notBlank()
.notExceedingLengthOf(60);
}
if (this.fromApiJsonHelper.parameterExists("partyName", element)) {
v.reset().parameter("partyName").value(this.fromApiJsonHelper.extractStringNamed("partyName", element)).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists("address1", element)) {
v.reset().parameter("address1").value(this.fromApiJsonHelper.extractStringNamed("address1", element)).notBlank()
.notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists("city", element)) {
v.reset().parameter("city").value(this.fromApiJsonHelper.extractStringNamed("city", element)).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists("pincode", element)) {
v.reset().parameter("pincode").value(this.fromApiJsonHelper.extractStringNamed("pincode", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("mobileNoSms", element)) {
v.reset().parameter("mobileNoSms").value(this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("operation", element)) {
v.reset().parameter("operation").value(this.fromApiJsonHelper.extractStringNamed("operation", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("adultMinor", element)) {
v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("openingDate", element)) {
v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull();
}
if (this.fromApiJsonHelper.parameterExists("dailyDepositAmount", element)) {
v.reset().parameter("dailyDepositAmount")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("dailyDepositAmount", element)).notNull()
.positiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("days", element)) {
v.reset().parameter("days").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element)).notNull()
.integerGreaterThanZero();
}
validateOptionalFields(element, v);
if (this.fromApiJsonHelper.parameterExists("nominees", element)) {
validateNominees(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForClose(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("closeDate", "dateFormat", "locale"));
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("closeDate").value(this.fromApiJsonHelper.extractLocalDateNamed("closeDate", element)).notNull();
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void validateOptionalFields(final JsonElement element, final DataValidatorBuilder v) {
optionalString(element, v, "lastAccount", 50);
optionalString(element, v, "aadharNo", 20);
optionalString(element, v, "partyName1", 100);
optionalString(element, v, "address2", 200);
optionalString(element, v, "address3", 200);
optionalString(element, v, "gender", 20);
optionalString(element, v, "panCardNo", 20);
optionalString(element, v, "telephone", 20);
optionalString(element, v, "occupation", 40);
optionalString(element, v, "risk", 20);
optionalString(element, v, "ckycId", 40);
optionalString(element, v, "minAccountOpt", 40);
optionalString(element, v, "relation", 40);
optionalString(element, v, "ledgerNo", 40);
optionalString(element, v, "withdrawType", 20);
optionalString(element, v, "spDescription", 500);
optionalString(element, v, "freezeCode", 20);
optionalString(element, v, "advCode", 40);
optionalString(element, v, "creditToBranch", 100);
optionalString(element, v, "creditToAccountType", 100);
optionalString(element, v, "creditToAccount", 50);
optionalString(element, v, "nomineeSplitMode", 20);
optionalString(element, v, "savingsAccountNo", 20);
if (this.fromApiJsonHelper.parameterExists("guardianClientId", element)) {
v.reset().parameter("guardianClientId").value(this.fromApiJsonHelper.extractLongNamed("guardianClientId", element))
.ignoreIfNull().integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("month", element)) {
v.reset().parameter("month").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("month", element)).ignoreIfNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("depositAmount", element)) {
v.reset().parameter("depositAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("depositAmount", element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("paymentAmount", element)) {
v.reset().parameter("paymentAmount")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("paymentAmount", element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("openingBalance", element)) {
v.reset().parameter("openingBalance")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("openingBalance", element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("interestRate", element)) {
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.ignoreIfNull().zeroOrPositiveAmount();
}
}
private void optionalString(final JsonElement element, final DataValidatorBuilder v, final String paramName, final int maxLength) {
if (this.fromApiJsonHelper.parameterExists(paramName, element)) {
v.reset().parameter(paramName).value(this.fromApiJsonHelper.extractStringNamed(paramName, element)).ignoreIfNull()
.notExceedingLengthOf(maxLength);
}
}
private void validateNominees(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party.nominee");
final var nomineesArray = topLevelJson.getAsJsonArray("nominees");
for (int i = 0; i < nomineesArray.size(); i++) {
final JsonObject nomineeJson = nomineesArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(nomineeJson, SUPPORTED_NOMINEE_PARAMETERS);
v.reset().parameterAtIndexArray("nominationDate", i).value(extractString(nomineeJson, "nominationDate")).notBlank();
v.reset().parameterAtIndexArray("name", i).value(extractString(nomineeJson, "name")).notBlank().notExceedingLengthOf(100);
v.reset().parameterAtIndexArray("relation", i).value(extractString(nomineeJson, "relation")).notBlank()
.notExceedingLengthOf(40);
v.reset().parameterAtIndexArray("gender", i).value(extractString(nomineeJson, "gender")).notBlank()
.notExceedingLengthOf(20);
}
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -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.dds.party.service;
import java.util.Collection;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
public interface PartyReadPlatformService {
Collection<PartyData> retrieveAll();
PartyData retrieveOne(Long partyId);
}

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