Compare commits

...
11 Commits
Author SHA1 Message Date
nitesh b1cfa996b4 feat(clients): fromDate/toDate submittedon_date range filter on GET /clients
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Backs the Client Approval tab's Today/Yesterday quick filters server-side.
offset/limit/displayName/orderBy/sortOrder/status already existed on this
endpoint; added fromDate/toDate (yyyy-MM-dd, inclusive) alongside them,
filtering on c.submittedon_date, following the same ::date-cast convention
already used by ClientPendingEditReadPlatformServiceImpl for the
equivalent pending-edits filter. Updated the one other caller
(SelfClientsApiResource) to pass null/null.
2026-07-30 10:33:48 +05:30
nitesh 02541eed6c feat(sanctions): expose the loaded list's data-currency date on every screening result
Screening only ever told a caller whether a name matched and when the
check ran — not which dated snapshot of the UN Security Council list it
ran against. Added SanctionsEntryRepository.findListLastLoadedOn()
(MAX(createdDate) over active entries) and threaded it through
SanctionsScreeningResult as listDataAsOf, so the frontend can show "List
Data As Of: <date>" alongside "Screened On".
2026-07-30 10:33:33 +05:30
niteshandClaude Sonnet 5 7c15fbb0c7 fix(documentmanagement): close leaked file handles causing Windows re-upload failures; add entity KYC document CodeValues
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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
FileContentStoreService.upload() opened a FileOutputStream (write) and an
InputStream (post-upload MIME check) without closing either. Windows keeps
those handles open for the JVM's lifetime, so the next upsert-style
delete-then-recreate of the same file (e.g. client signature/photo
re-upload) failed with FileSystemException: "being used by another
process". Wrapped both in try-with-resources.

Also adds every distinct non-individual/constitution KYC document name
(HUF, Private/Public Ltd, LLP, Partnership, Trust, Society, Proprietorship,
Association, Govt/PSU, Cooperative) as a KYCIdProofType code value.
resolveDocumentTypeId() on the frontend matches the picked document name
against this list before creating a client_identifier; since these entity
document names had no corresponding code value, every non-individual
client's KYC document upload silently failed to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 11:34:18 +05:30
nitesh 6e3f195de0 Merge remote-tracking branch 'origin/uate' into uate
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-07-29 10:31:58 +05:30
niteshandClaude Sonnet 5 a65d801250 feat(clients): maker-checker for client creation and edits (backend)
- Client activation now requires a different user than the one who created the client
  (client.getCreatedBy() vs current user) — no bypass, including CHECKER_SUPER_USER, since a
  bypass would recreate the exact "role bypasses the hold" loophole this closes. Client creation
  itself is unaffected (the wizard needs a real client id immediately for photo/KYC/address/family
  follow-up uploads), only activation is gated.
- New maker-checker queue for Client edits, modelled on this codebase's existing
  m_share_account_pending_edit / chequebook patterns rather than Fineract's native
  command-approval engine (which any ALL_FUNCTIONS role bypasses unconditionally):
  - m_client_pending_edit (migrations 0429-0431): stores the maker's raw edit payload plus a
    point-in-time "before" snapshot (original_json, extracted from the frontend's _originalValues
    key) so the checker's diff stays accurate even after the edit is approved/rejected, when the
    live client no longer reflects the pre-edit state.
  - ClientEditWritePlatformService: submitEditForApproval() stages the edit (refusing a second
    submission while one's already pending, via a nullable-but-unique pending_lock_key);
    approveEdit() replays the payload through the existing updateClient() so all of Client's
    validation/change-detection runs unchanged, just deferred; rejectEdit() discards it. The maker
    can never approve/reject their own submission.
  - ClientEditApiResource: /clients/{id}/edit (submit), /clients/edit/pending (paginated +
    status/clientName/date-range filterable list, mirrors /chequebook/pending-requests),
    /clients/{id}/edit/approve, /clients/{id}/edit/reject. New permissions (migration 0430):
    SUBMITEDIT_CLIENT, APPROVEEDIT_CLIENT, REJECTEDIT_CLIENT, APPROVEEDIT_CLIENT_CHECKER (the
    last one surfaces pending edits in the generic Checker Inbox too).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 10:31:10 +05:30
keshav f45d5890ea CTR/STR report and Risk Category auto mark on bases of person occupation and anual salary enter
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-07-29 10:22:27 +05:30
niteshandClaude Sonnet 5 3c58bb231f fix(clients): pwdCategoryOptions on edit template, minor-age threshold to 18, minor KYC identity-proof bypass
Fineract Cargo & Unit- & Integration tests - MySQL / 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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-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
- ClientData.templateOnTop()/templateWithSavingAccountOptions() never copied pwdCategoryOptions
  from the template — the Disability Type dropdown was always empty on the edit-mode GET
  /clients/{id}?template=true call, even though create-time template() had it.
- Raise the minor-detection threshold in ClientKycApiResource from age <10 to <18 (legal
  definition), mirroring the frontend's own rule so the required-documents list served here
  stays consistent.
- ClientKycCommand.validateForInitiate() unconditionally required at least one of
  PAN/Aadhaar/GSTIN, with no awareness of the Birth Certificate/School ID document-type picker
  minors actually use — blocking "Save & Next" on the KYC tab for every minor client. Added a
  skipIdentityDocumentCheck flag, set from client.getIsMinor() at both call sites
  (initiateKyc/initiateKycInline).
- Added Birth Certificate and School ID Card as KYCIdProofType code values — they were already
  offered as document-type options for minors but never existed in the code-value list that
  saveKycDocProof() resolves against, so selecting either silently failed to save any
  identifier/document.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:51:08 +05:30
niteshandClaude Sonnet 5 aabda18b69 feat(clients): enforce Mother + Father/Caretaker family members on client activation
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
- Add CARETAKER as a new RELATIONSHIP code value (previously missing — a client with no
  father had no valid way to satisfy the rule below).
- Add ClientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(),
  called from activateClient(): requires an active "Mother" family member, plus either
  "Father" or "Caretaker", for individual clients (non-individual entities are exempt).
- Two distinct exceptions (ClientFamilyMemberMotherRequiredException /
  ClientFamilyMemberFatherOrCaretakerRequiredException) so the two failure reasons surface
  as separate errors instead of one merged message, mirroring the frontend's separate banners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:37:24 +05:30
nitesh 0e5671b706 Merge remote-tracking branch 'origin/uate' into uate
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-07-28 10:55:05 +05:30
niteshandClaude Sonnet 5 67214ec0f8 fix(clients): stale signature bug, CKYC fixes, monthly turnover/annual income, senior citizen/PWD classification
- Add m_client.signature_id (single-slot pointer, mirrors image_id) plus
  ClientSignatureIdAdapter/EntitySignatureIdAdapter so re-uploading a signature updates the
  existing m_document row in place instead of leaving duplicate rows behind that readers picked
  inconsistently.
- Fix CkycBatchBuilderServiceImpl address-proof classification to read the client identifier's
  actual m_code (KYCAddressProofType vs KYCIdProofType) instead of a hardcoded string set that
  disagreed with the CKYC tab and silently dropped Aadhaar-as-address-proof identifiers.
- Organize the CKYC ZIP into per-artefact-type folders (PHOTO/SIGNATURE/ID_PROOF/ADDRESS_PROOF).
- Fix CkycBatchBuilderServiceImpl.createBatch() using the daily batch sequence number as the ZIP
  document's parent_entity_id instead of the real m_ckyc_batch.id, which 404'd on download
  whenever the two diverged (i.e. almost always).
- Add monthlyTurnover and annualIncome fields alongside the existing annualTurnover/monthlyIncome.
- Add isSeniorCitizen, isPwd, and pwdCategory (proper m_code_value-backed PwdCategory code, not a
  free-text column) to m_client, following the same pattern as occupation/religion/marital status.
- Add Diploma as its own Education Level (with diploma-specific DegreeQualification values) and
  Graduate as a level distinct from Undergraduate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 10:50:16 +05:30
keshav 026d902199 minor change on client
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-07-28 10:43:06 +05:30
219 changed files with 3686 additions and 681 deletions
@@ -18,7 +18,6 @@
*/
package org.apache.fineract.accounting.journalentry;
import java.time.OffsetDateTime;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.journalentry.data.JournalEntryData;
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
@@ -277,14 +277,14 @@ public class ProductToGLAccountMappingHelper {
}
final JsonArray chargeToIncomeAccountMappingArray = this.fromApiJsonHelper.extractJsonArrayNamed(arrayFragmentName, element);
/**
/*
* Variable stores a map representation of charges (key) and their associated income Id's (value) extracted from
* the passed in Jsoncommand
**/
*/
final Map<Long, Long> inputChargeToIncomeAccountMap = new HashMap<>();
/***
/*
* Variable stores all charges which have already been mapped to Income Accounts in the system
**/
*/
final Set<Long> existingCharges = new HashSet<>();
if (chargeToIncomeAccountMappingArray != null) {
if (changes != null) {
@@ -301,13 +301,13 @@ public class ProductToGLAccountMappingHelper {
// If input map is empty, delete all existing mappings
if (inputChargeToIncomeAccountMap.size() == 0) {
this.accountMappingRepository.deleteAll(existingChargeToIncomeAccountMappings);
} /**
} /*
* Else, <br/>
* update existing mappings OR <br/>
* delete old mappings (which are already present, but not passed in as a part of Jsoncommand)<br/>
* Create new mappings for charges that are passed in as a part of the Jsoncommand but not already present
*
**/
*/
else {
for (final ProductToGLAccountMapping chargeToIncomeAccountMapping : existingChargeToIncomeAccountMappings) {
final Long currentCharge = chargeToIncomeAccountMapping.getCharge().getId();
@@ -356,14 +356,14 @@ public class ProductToGLAccountMappingHelper {
.findAllPaymentTypeToFundSourceMappings(productId, portfolioProductType.getValue());
final JsonArray paymentChannelMappingArray = this.fromApiJsonHelper
.extractJsonArrayNamed(LoanProductAccountingParams.PAYMENT_CHANNEL_FUND_SOURCE_MAPPING.getValue(), element);
/**
/*
* Variable stores a map representation of Payment channels (key) and their fund sources (value) extracted from
* the passed in Jsoncommand
**/
*/
final Map<Long, Long> inputPaymentChannelFundSourceMap = new HashMap<>();
/***
/*
* Variable stores all payment types which have already been mapped to Fund Sources in the system
**/
*/
final Set<Long> existingPaymentTypes = new HashSet<>();
if (paymentChannelMappingArray != null) {
if (changes != null) {
@@ -381,14 +381,14 @@ public class ProductToGLAccountMappingHelper {
// If input map is empty, delete all existing mappings
if (inputPaymentChannelFundSourceMap.isEmpty()) {
this.accountMappingRepository.deleteAll(existingPaymentChannelToFundSourceMappings);
} /**
} /*
* Else, <br/>
* update existing mappings OR <br/>
* delete old mappings (which re already present, but not passed in as a part of Jsoncommand)<br/>
* Create new mappings for payment types that are passed in as a part of the Jsoncommand but not already
* present
*
**/
*/
else {
for (final ProductToGLAccountMapping existingPaymentChannelToFundSourceMapping : existingPaymentChannelToFundSourceMappings) {
final Long currentPaymentChannelId = existingPaymentChannelToFundSourceMapping.getPaymentType().getId();
@@ -584,9 +584,9 @@ public class ProductToGLAccountMappingHelper {
// based on input condition
GLAccount glAccount;
/**
/*
* Both CASH and Accrual placeholders have the same value for income from Interest and penalties
**/
*/
CashAccountsForLoan placeHolderAccountType;
if (isPenalty) {
glAccount = getAccountByIdAndType(LoanProductAccountingParams.INCOME_ACCOUNT_ID.getValue(), GLAccountType.INCOME,
@@ -181,12 +181,12 @@ public class TellerApiResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create Cashiers", description = """
Mandatory Fields:\s
Mandatory Fields:
Cashier/staff, Fromm Date, To Date, Full Day or From time and To time
Optional Fields:\s
Optional Fields:
Description/Notes""")
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = TellerApiResourceSwagger.PostTellersTellerIdCashiersRequest.class)))
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = TellerApiResourceSwagger.PostTellersTellerIdCashiersResponse.class)))
@@ -0,0 +1,56 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 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.documentmanagement.adapter;
import java.io.Serial;
import java.io.Serializable;
import java.util.Optional;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Decouples the entity "current signature" pointer from the generic document service, the same
* way {@link EntityImageIdAdapter} decouples the "current photo" pointer — lets
* DocumentWritePlatformServiceImpl (fineract-document module) give single-slot upsert semantics to
* signature uploads without depending on portfolio-module entities directly.
*/
public interface EntitySignatureIdAdapter {
/** @param documentName the m_document.name of the document being created/removed */
boolean accept(String entityType, String documentName);
Optional<SignatureIdResult> get(Long entityId);
Optional<SignatureIdResult> set(Long entityId, Long signatureId);
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
class SignatureIdResult implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Long id;
private String displayName;
}
}
@@ -23,6 +23,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Simulator-side audit log for CFCFRMS (I4C/MHA) events.
@@ -69,7 +70,7 @@ public class CfcfrmsSimulatorLog extends AbstractAuditableWithUTCDateTimeCustom<
this.eventType = eventType;
this.payload = payload;
this.responsePayload = responsePayload;
this.eventTimestamp = OffsetDateTime.now();
this.eventTimestamp = DateUtils.getOffsetDateTimeOfTenant();
}
public CfcfrmsSimulatorLog(final String cfcfrmsRef, final String ticketId, final String bankIfsc,
@@ -23,6 +23,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Audit trail for every status change on a CFCFRMS complaint ticket.
@@ -54,7 +55,7 @@ public class ComplaintAuditLog extends AbstractAuditableWithUTCDateTimeCustom<Lo
this.event = event;
this.description = description;
this.actor = actor;
this.eventTimestamp = OffsetDateTime.now();
this.eventTimestamp = DateUtils.getOffsetDateTimeOfTenant();
}
public ComplaintAuditLog(final String ticketId, final String event, final String description,
@@ -26,6 +26,7 @@ import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* CFCFRMS Complaint Ticket — represents a cyber fraud complaint pushed by MHA/I4C to the bank.
@@ -121,7 +122,7 @@ public class ComplaintTicket extends AbstractAuditableWithUTCDateTimeCustom<Long
ticket.status = ComplaintStatus.RECEIVED;
ticket.assignedOfficer = assignedOfficer;
ticket.statePoliceRef = statePoliceRef;
ticket.receivedAt = OffsetDateTime.now();
ticket.receivedAt = DateUtils.getOffsetDateTimeOfTenant();
return ticket;
}
@@ -132,7 +133,7 @@ public class ComplaintTicket extends AbstractAuditableWithUTCDateTimeCustom<Long
this.status = newStatus;
this.actionTaken = actionNote;
if (newStatus == ComplaintStatus.RESOLVED || newStatus == ComplaintStatus.REJECTED) {
this.resolvedAt = OffsetDateTime.now();
this.resolvedAt = DateUtils.getOffsetDateTimeOfTenant();
}
}
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.adapter;
import static java.util.Objects.nonNull;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.documentmanagement.adapter.EntitySignatureIdAdapter;
import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
@Component
class ClientSignatureIdAdapter implements EntitySignatureIdAdapter {
private static final String ENTITY_TYPE = "clients";
private static final String DOCUMENT_NAME = "clientSignature";
private final ClientRepository repository;
@Override
public boolean accept(String entityType, String documentName) {
return ENTITY_TYPE.equalsIgnoreCase(entityType) && DOCUMENT_NAME.equalsIgnoreCase(documentName);
}
@Override
@Transactional(readOnly = true)
public Optional<SignatureIdResult> get(Long entityId) {
return repository.findById(entityId).filter(client -> nonNull(client.getSignatureId()))
.map(client -> SignatureIdResult.builder().id(client.getSignatureId()).displayName(client.getDisplayName()).build());
}
@Override
@Transactional
public Optional<SignatureIdResult> set(Long entityId, Long signatureId) {
final var result = get(entityId);
if (signatureId == null) {
repository.removeSignatureId(entityId);
} else {
repository.updateByIdAndSignatureId(entityId, signatureId);
}
return result;
}
}
@@ -63,6 +63,7 @@ public class ClientApiConstants {
public static final String CLIENT_CLASSIFICATION = "ClientClassification";
public static final String OCCUPATION_CATEGORY = "OccupationCategory";
public static final String PWD_CATEGORY = "PwdCategory";
public static final String OCCUPATION = "Occupation";
public static final String CLIENT_NON_PERSON_CONSTITUTION = "Constitution";
@@ -147,7 +148,13 @@ public class ClientApiConstants {
public static final String religionParamName = "religion";
public static final String socialCategoryParamName = "socialCategory";
public static final String monthlyIncomeParamName = "monthlyIncome";
public static final String annualIncomeParamName = "annualIncome";
public static final String annualTurnoverParamName = "annualTurnover";
public static final String monthlyTurnoverParamName = "monthlyTurnover";
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
public static final String isPwdParamName = "isPwd";
public static final String pwdCategoryIdParamName = "pwdCategoryId";
public static final String pwdCategoryParamName = "pwdCategory";
public static final String agricultureIncomeParamName = "agricultureIncome";
public static final String nonAgricultureIncomeParamName = "nonAgricultureIncome";
public static final String ageYearsParamName = "ageYears";
@@ -257,8 +264,9 @@ public class ClientApiConstants {
staffOptionsParamName, dateOfBirthParamName, genderParamName, clientTypeParamName, clientClassificationParamName,
salutationParamName, occupationCategoryParamName, occupationParamName, maritalStatusParamName,
educationLevelParamName, degreeQualificationParamName,
religionParamName, socialCategoryParamName, monthlyIncomeParamName,
annualTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName,
religionParamName, socialCategoryParamName, monthlyIncomeParamName, annualIncomeParamName,
annualTurnoverParamName, monthlyTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName,
isSeniorCitizenParamName, isPwdParamName, pwdCategoryParamName,
ageYearsParamName, ageMonthsParamName, ageDaysParamName,
nationalityIdParamName, isMinorParamName, guardianClientIdParamName, guardianClientDisplayNameParamName, guardianRelationParamName,
dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName,
@@ -82,7 +82,13 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
private CodeValueData religion;
private CodeValueData socialCategory;
private BigDecimal monthlyIncome;
private BigDecimal annualIncome;
private BigDecimal annualTurnover;
private BigDecimal monthlyTurnover;
private Boolean isSeniorCitizen;
private Boolean isPwd;
private CodeValueData pwdCategory;
private Collection<CodeValueData> pwdCategoryOptions;
private BigDecimal agricultureIncome;
private BigDecimal nonAgricultureIncome;
private Integer ageYears;
@@ -400,13 +406,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.degreeQualificationOptions = templateData.degreeQualificationOptions;
data.religionOptions = templateData.religionOptions;
data.socialCategoryOptions = templateData.socialCategoryOptions;
data.pwdCategoryOptions = templateData.pwdCategoryOptions;
data.maritalStatus = clientData.maritalStatus;
data.educationLevel = clientData.educationLevel;
data.degreeQualification = clientData.degreeQualification;
data.religion = clientData.religion;
data.socialCategory = clientData.socialCategory;
data.monthlyIncome = clientData.monthlyIncome;
data.annualIncome = clientData.annualIncome;
data.annualTurnover = clientData.annualTurnover;
data.monthlyTurnover = clientData.monthlyTurnover;
data.isSeniorCitizen = clientData.isSeniorCitizen;
data.isPwd = clientData.isPwd;
data.pwdCategory = clientData.pwdCategory;
data.agricultureIncome = clientData.agricultureIncome;
data.nonAgricultureIncome = clientData.nonAgricultureIncome;
data.ageYears = clientData.ageYears;
@@ -456,13 +468,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.degreeQualificationOptions = clientData.degreeQualificationOptions;
data.religionOptions = clientData.religionOptions;
data.socialCategoryOptions = clientData.socialCategoryOptions;
data.pwdCategoryOptions = clientData.pwdCategoryOptions;
data.maritalStatus = clientData.maritalStatus;
data.educationLevel = clientData.educationLevel;
data.degreeQualification = clientData.degreeQualification;
data.religion = clientData.religion;
data.socialCategory = clientData.socialCategory;
data.monthlyIncome = clientData.monthlyIncome;
data.annualIncome = clientData.annualIncome;
data.annualTurnover = clientData.annualTurnover;
data.monthlyTurnover = clientData.monthlyTurnover;
data.isSeniorCitizen = clientData.isSeniorCitizen;
data.isPwd = clientData.isPwd;
data.pwdCategory = clientData.pwdCategory;
data.agricultureIncome = clientData.agricultureIncome;
data.nonAgricultureIncome = clientData.nonAgricultureIncome;
data.ageYears = clientData.ageYears;
@@ -509,7 +527,12 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.religion = clientData.religion;
data.socialCategory = clientData.socialCategory;
data.monthlyIncome = clientData.monthlyIncome;
data.annualIncome = clientData.annualIncome;
data.annualTurnover = clientData.annualTurnover;
data.monthlyTurnover = clientData.monthlyTurnover;
data.isSeniorCitizen = clientData.isSeniorCitizen;
data.isPwd = clientData.isPwd;
data.pwdCategory = clientData.pwdCategory;
data.agricultureIncome = clientData.agricultureIncome;
data.nonAgricultureIncome = clientData.nonAgricultureIncome;
data.ageYears = clientData.ageYears;
@@ -77,6 +77,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "image_id")
private Long imageId;
@Column(name = "signature_id")
private Long signatureId;
@Column(name = "status_enum", nullable = false)
private Integer status;
@@ -231,9 +234,25 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "monthly_income")
private java.math.BigDecimal monthlyIncome;
@Column(name = "annual_income")
private java.math.BigDecimal annualIncome;
@Column(name = "annual_turnover")
private java.math.BigDecimal annualTurnover;
@Column(name = "monthly_turnover")
private java.math.BigDecimal monthlyTurnover;
@Column(name = "is_senior_citizen")
private Boolean isSeniorCitizen;
@Column(name = "is_pwd")
private Boolean isPwd;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pwd_category_cv_id")
private CodeValue pwdCategory;
@Column(name = "agriculture_income")
private java.math.BigDecimal agricultureIncome;
@@ -832,6 +851,14 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.socialCategory != null ? this.socialCategory.getId() : null;
}
public Long pwdCategoryId() {
return this.pwdCategory != null ? this.pwdCategory.getId() : null;
}
public void updatePwdCategory(CodeValue pwdCategory) {
this.pwdCategory = pwdCategory;
}
public void updateSocialCategory(CodeValue socialCategory) {
this.socialCategory = socialCategory;
}
@@ -29,6 +29,7 @@ import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Form 60 declaration as per Rule 114B of Income Tax Rules, 1962.
@@ -87,7 +88,7 @@ public class ClientForm60 extends AbstractAuditableWithUTCDateTimeCustom<Long> {
form.estimatedTotalIncome = command.bigDecimalValueOfParameterNamed("estimatedTotalIncome");
form.reasonForNoPan = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamed("reasonForNoPan"), null);
form.declaration = command.booleanPrimitiveValueOfParameterNamed("declaration");
form.submittedOn = LocalDate.now();
form.submittedOn = DateUtils.getLocalDateOfTenant();
return form;
}
}
@@ -32,6 +32,7 @@ import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Represents a client's KYC (Know Your Customer) record as mandated by the Reserve Bank of India
@@ -260,7 +261,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.gstinVerified = false;
this.cinVerified = false;
this.dinVerified = false;
this.initiatedOn = LocalDate.now();
this.initiatedOn = DateUtils.getLocalDateOfTenant();
}
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
@@ -456,13 +457,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markCkycUploaded(final String ckycNumber, final String batchId) {
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
this.ckycUploadedOn = LocalDate.now();
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
}
/** Called after successful 3rd-party verification of all required documents. */
public void markVerified(final String ckycNumber) {
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
this.verifiedOn = LocalDate.now();
this.verifiedOn = DateUtils.getLocalDateOfTenant();
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
@@ -474,14 +475,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
/** Called when verification fails. */
public void markRejected(final String remarks) {
this.kycStatus = ClientKycStatus.REJECTED.getValue();
this.rejectedOn = LocalDate.now();
this.rejectedOn = DateUtils.getLocalDateOfTenant();
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
}
public void markPanVerified(final String fullName, final String firstName, final String lastName,
final String aadhaarSeedingStatus, final String category, final String status) {
this.panVerified = true;
this.panVerifiedOn = LocalDate.now();
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
@@ -507,28 +508,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markAadhaarVerified() {
this.aadhaarVerified = true;
this.aadhaarVerifiedOn = LocalDate.now();
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
}
public void markGstinVerified() {
this.gstinVerified = true;
this.gstinVerifiedOn = LocalDate.now();
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
}
public void markCinVerified() {
this.cinVerified = true;
this.cinVerifiedOn = LocalDate.now();
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
}
public void markDinVerified() {
this.dinVerified = true;
this.dinVerifiedOn = LocalDate.now();
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
}
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
this.dinNumber = dinNumber;
this.dinVerified = true;
this.dinVerifiedOn = LocalDate.now();
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.isDirector = true;
this.dinMcaResponse = mcaResponseJson;
}
@@ -55,6 +55,14 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
@Query("UPDATE Client client SET client.imageId = null WHERE client.id = :clientId")
void removeImageId(@Param("clientId") Long clientId);
@Modifying
@Query("UPDATE Client client SET client.signatureId = :signatureId WHERE client.id = :clientId")
void updateByIdAndSignatureId(@Param("clientId") Long clientId, @Param("signatureId") Long signatureId);
@Modifying
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
void removeSignatureId(@Param("clientId") Long clientId);
/**
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
@@ -0,0 +1,138 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
@Entity
@Table(name = "m_client_risk_rule")
public class ClientRiskRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "rule_name", nullable = false, length = 100)
private String ruleName;
@Column(name = "legal_form_id")
private Integer legalFormId;
@ManyToOne
@JoinColumn(name = "occupation_category_cv_id")
private CodeValue occupationCategory;
@ManyToOne
@JoinColumn(name = "occupation_cv_id")
private CodeValue occupation;
@Column(name = "min_monthly_income", precision = 19, scale = 6)
private BigDecimal minMonthlyIncome;
@Column(name = "max_monthly_income", precision = 19, scale = 6)
private BigDecimal maxMonthlyIncome;
@Column(name = "min_annual_income", precision = 19, scale = 6)
private BigDecimal minAnnualIncome;
@Column(name = "max_annual_income", precision = 19, scale = 6)
private BigDecimal maxAnnualIncome;
@Column(name = "min_annual_turnover", precision = 19, scale = 6)
private BigDecimal minAnnualTurnover;
@Column(name = "max_annual_turnover", precision = 19, scale = 6)
private BigDecimal maxAnnualTurnover;
@Column(name = "risk_category", nullable = false)
private Integer riskCategory;
@Column(name = "reason", nullable = false, length = 255)
private String reason;
@Column(name = "order_position", nullable = false)
private Integer orderPosition;
protected ClientRiskRule() {}
public Long getId() {
return id;
}
public String getRuleName() {
return ruleName;
}
public Integer getLegalFormId() {
return legalFormId;
}
public CodeValue getOccupationCategory() {
return occupationCategory;
}
public CodeValue getOccupation() {
return occupation;
}
public BigDecimal getMinMonthlyIncome() {
return minMonthlyIncome;
}
public BigDecimal getMaxMonthlyIncome() {
return maxMonthlyIncome;
}
public BigDecimal getMinAnnualIncome() {
return minAnnualIncome;
}
public BigDecimal getMaxAnnualIncome() {
return maxAnnualIncome;
}
public BigDecimal getMinAnnualTurnover() {
return minAnnualTurnover;
}
public BigDecimal getMaxAnnualTurnover() {
return maxAnnualTurnover;
}
public Integer getRiskCategory() {
return riskCategory;
}
public String getReason() {
return reason;
}
public Integer getOrderPosition() {
return orderPosition;
}
}
@@ -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.client.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule, Long> {
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
List<ClientRiskRule> findAllOrderedByOrderPosition();
}
@@ -24,6 +24,7 @@ import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Activity log for each DIP / MNRL mobile number lookup request.
@@ -78,6 +79,6 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
this.rawResponse = rawResponse;
this.lookupSource = lookupSource;
this.clientId = clientId;
this.lookedUpOn = OffsetDateTime.now();
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
}
}
@@ -18,7 +18,9 @@
*/
package org.apache.fineract.portfolio.sanctions.domain;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -40,4 +42,11 @@ public interface SanctionsEntryRepository extends JpaRepository<SanctionsEntry,
void deleteAllByActiveTrue();
long countByActiveTrue();
/**
* Timestamp the active sanctions list was last loaded into the database — i.e. the data
* currency shown to callers so they know which list snapshot a screening ran against.
*/
@Query("SELECT MAX(s.createdDate) FROM SanctionsEntry s WHERE s.active = true")
Optional<OffsetDateTime> findListLastLoadedOn();
}
@@ -23,6 +23,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Activity log for each sanctions screening request.
@@ -73,7 +74,7 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
this.screeningSource = screeningSource;
this.clientId = clientId;
this.ipAddress = ipAddress;
this.screenedOn = OffsetDateTime.now();
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
}
// ── Accessors ─────────────────────────────────────────────────────────────
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.search.data;
import java.util.Locale;
import org.apache.fineract.portfolio.search.SearchConstants.SearchSupportedResources;
public class SearchConditions {
@@ -37,17 +38,17 @@ public class SearchConditions {
this.searchResource = searchResource;
this.exactMatch = exactMatch;
this.clientSearch = null == searchResource
|| searchResource.toLowerCase().contains(SearchSupportedResources.CLIENTS.name().toLowerCase());
|| searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.CLIENTS.name().toLowerCase(Locale.ROOT));
this.groupSearch = null == searchResource
|| searchResource.toLowerCase().contains(SearchSupportedResources.GROUPS.name().toLowerCase());
|| searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.GROUPS.name().toLowerCase(Locale.ROOT));
this.loanSeach = null == searchResource
|| searchResource.toLowerCase().contains(SearchSupportedResources.LOANS.name().toLowerCase());
|| searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.LOANS.name().toLowerCase(Locale.ROOT));
this.savingSeach = null == searchResource
|| searchResource.toLowerCase().contains(SearchSupportedResources.SAVINGS.name().toLowerCase());
|| searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.SAVINGS.name().toLowerCase(Locale.ROOT));
this.shareSeach = null == searchResource
|| searchResource.toLowerCase().contains(SearchSupportedResources.SHARES.name().toLowerCase());
|| searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.SHARES.name().toLowerCase(Locale.ROOT));
this.clientIdentifierSearch = null == searchResource
|| searchResource.toLowerCase().contains(SearchSupportedResources.CLIENTIDENTIFIERS.name().toLowerCase());
|| searchResource.toLowerCase(Locale.ROOT).contains(SearchSupportedResources.CLIENTIDENTIFIERS.name().toLowerCase(Locale.ROOT));
}
public SearchConditions(final String searchQueryParam, final String searchResource, final Boolean clientSearch,
@@ -321,6 +321,6 @@ public class SearchUtil {
public String camelToSnake(final String camelStr) {
return camelStr == null ? null
: camelStr.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2").replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
: camelStr.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2").replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
}
}
@@ -39,7 +39,7 @@ public interface BankUserDscRepository extends JpaRepository<BankUserDsc, Long>
/**
* Login gate: returns true if the user has at least one usable DSC mapping that's also not
* past its notAfter date. The {@code dscExpiryDate &gt;= today} clause prevents 2FA-gating a
* past its notAfter date. The {@code dscExpiryDate >= today} clause prevents 2FA-gating a
* user who's enrolled but whose cert silently expired between log-ins — they'd otherwise be
* stuck in a verification loop with a cert the helper would sign with but the server would
* accept (the verify side filters too, but the gate is what kicks the user into the DSC flow
@@ -29,6 +29,7 @@ import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser;
/**
@@ -452,7 +453,7 @@ public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
public CurrentAccountPendingEdit(final CurrentAccount currentAccount, final AppUser maker) {
this.currentAccount = currentAccount;
this.maker = maker;
this.createdOn = LocalDateTime.now();
this.createdOn = DateUtils.getLocalDateTimeOfTenant();
this.status = STATUS_PENDING;
this.pendingLockKey = lockKeyFor(currentAccount.getId());
}
@@ -582,14 +583,14 @@ public class CurrentAccountPendingEdit extends AbstractPersistableCustom<Long> {
public void markApproved(final AppUser checker) {
this.status = STATUS_APPROVED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.checkedOn = DateUtils.getLocalDateTimeOfTenant();
this.pendingLockKey = null;
}
public void markRejected(final AppUser checker, final String reason) {
this.status = STATUS_REJECTED;
this.checker = checker;
this.checkedOn = LocalDateTime.now();
this.checkedOn = DateUtils.getLocalDateTimeOfTenant();
this.rejectReason = reason;
this.pendingLockKey = null;
}
@@ -51,7 +51,9 @@ public class CurrentAccountNumberGenerator {
if (val > maxVal) {
maxVal = val;
}
} catch (NumberFormatException ignored) {}
} catch (NumberFormatException ignored) {
// non-numeric suffix - skip when computing the max existing sequence
}
}
}
@@ -54,7 +54,6 @@ import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.MonthDay;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
@@ -76,7 +75,6 @@ import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct;
import org.apache.fineract.portfolio.savings.data.SavingsProductAccountingDataValidator;
@@ -56,18 +56,11 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountT
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.customerSegmentParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.productCodeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.glAccountIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.effectiveDateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.effectiveToParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.endDateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominationFacilityParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dormancyAlertParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.interestCalculateSelectorParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.averagePeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoPostParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.frequencyFixDayParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lastInterestDateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enableLockinPeriodParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.balanceSwapOptionParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.msmeParamName;
@@ -26,6 +26,7 @@ import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Entity
@Table(name = "m_current_product_deposit_limit_kyc")
@@ -57,12 +58,12 @@ public class CurrentProductDepositLimitKyc extends AbstractPersistableCustom<Lon
private LocalDateTime createdDate;
protected CurrentProductDepositLimitKyc() {
this.createdDate = LocalDateTime.now();
}
public CurrentProductDepositLimitKyc(CurrentProduct currentProduct) {
this();
this.currentProduct = currentProduct;
this.createdDate = DateUtils.getLocalDateTimeOfTenant();
}
public CurrentProduct getCurrentProduct() { return currentProduct; }
@@ -26,6 +26,7 @@ import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Entity
@Table(name = "m_current_product_facility_config")
@@ -87,12 +88,12 @@ public class CurrentProductFacilityConfig extends AbstractPersistableCustom<Long
private LocalDateTime createdDate;
protected CurrentProductFacilityConfig() {
this.createdDate = LocalDateTime.now();
}
public CurrentProductFacilityConfig(CurrentProduct currentProduct) {
this();
this.currentProduct = currentProduct;
this.createdDate = DateUtils.getLocalDateTimeOfTenant();
}
public CurrentProduct getCurrentProduct() { return currentProduct; }
@@ -26,6 +26,7 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Entity
@Table(name = "m_current_product_facility_limits")
@@ -51,7 +52,6 @@ public class CurrentProductFacilityLimit extends AbstractPersistableCustom<Long>
private LocalDateTime createdDate;
protected CurrentProductFacilityLimit() {
this.createdDate = LocalDateTime.now();
}
private CurrentProductFacilityLimit(CurrentProduct currentProduct, String limitType,
@@ -62,6 +62,7 @@ public class CurrentProductFacilityLimit extends AbstractPersistableCustom<Long>
this.generalLimit = generalLimit;
this.minorAccountLimit = minorAccountLimit;
this.smallSavingLimit = smallSavingLimit;
this.createdDate = DateUtils.getLocalDateTimeOfTenant();
}
public static CurrentProductFacilityLimit create(CurrentProduct currentProduct, String limitType,
@@ -19,13 +19,11 @@
package org.apache.fineract.portfolio.currentproduct.service;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
@@ -95,23 +95,24 @@ public class FileContentStoreService implements ContentStoreService {
final var target = Files.exists(getPath(safePath, false)) ? getPath(safePath, false) : getPath(safePath, true);
try (var in = is) {
IOUtils.copy(in, new FileOutputStream(target.toString()));
try (var in = is; var out = new FileOutputStream(target.toString())) {
IOUtils.copy(in, out);
} catch (Exception e) {
throw new ContentStoreException(e);
}
final var relativePath = getRelativePath(target);
try {
postUploadContentPolicy
.check(ContentPolicyContext.builder().path(path).inputStream(getInputStream(target)).mimeType(mimeType).build());
try (var in = getInputStream(target)) {
postUploadContentPolicy.check(ContentPolicyContext.builder().path(path).inputStream(in).mimeType(mimeType).build());
} catch (ContentPolicyException e) {
log.warn("Delete file because it didn't comply with the post-upload policy check: {} - {}", target, e.getMessage());
delete(relativePath.toString());
throw e;
} catch (IOException e) {
throw new ContentStoreException(e);
}
return relativePath.toString();
@@ -21,6 +21,7 @@ package org.apache.fineract.infrastructure.documentmanagement.service;
import static java.util.Objects.requireNonNull;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
@@ -31,6 +32,7 @@ import org.apache.commons.lang3.Strings;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.documentmanagement.adapter.EntitySignatureIdAdapter;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateRequest;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteRequest;
@@ -59,6 +61,7 @@ public class DocumentWritePlatformServiceImpl implements DocumentWritePlatformSe
private final ContentStoreService storeService;
private final BusinessEventNotifierService businessEventNotifierService;
private final FineractProperties properties;
private final List<EntitySignatureIdAdapter> signatureIdAdapters;
@Transactional
@Override
@@ -74,13 +77,30 @@ public class DocumentWritePlatformServiceImpl implements DocumentWritePlatformSe
path = storeService.upload(path, request.getStream(), request.getType());
final var doc = new Document().setParentEntityType(request.getEntityType()).setParentEntityId(request.getEntityId())
.setName(Optional.ofNullable(request.getName()).orElse(request.getFileName())).setFileName(request.getFileName())
.setSize(request.getSize()).setType(request.getType()).setDescription(request.getDescription()).setLocation(path)
.setStorageType(storeService.getType().getValue());
final var docName = Optional.ofNullable(request.getName()).orElse(request.getFileName());
// Some document "slots" (e.g. a client's signature) are single-slot, mirroring
// m_client.image_id: re-upload should replace the existing row in place rather than
// leave the old one behind as an orphan whose ordering readers then disagree about.
// Adapters recognize their own (entityType, name) convention and own the pointer.
final var signatureAdapter = signatureIdAdapters.stream()
.filter(adapter -> adapter.accept(request.getEntityType(), docName)).findFirst();
final var doc = signatureAdapter.flatMap(adapter -> adapter.get(request.getEntityId()))
.flatMap(result -> documentRepository.findById(result.getId())).map(existing -> {
// replacing an existing single-slot document: drop the old file, reuse the row
storeService.delete(existing.getLocation());
return existing;
}).orElseGet(Document::new);
doc.setParentEntityType(request.getEntityType()).setParentEntityId(request.getEntityId()).setName(docName)
.setFileName(request.getFileName()).setSize(request.getSize()).setType(request.getType())
.setDescription(request.getDescription()).setLocation(path).setStorageType(storeService.getType().getValue());
documentRepository.save(doc);
signatureAdapter.ifPresent(adapter -> adapter.set(request.getEntityId(), doc.getId()));
businessEventNotifierService.notifyPostBusinessEvent(new DocumentCreatedBusinessEvent(documentMapper.map(doc)));
return DocumentCreateResponse.builder().resourceId(doc.getId()).resourceIdentifier(request.getEntityType()).build();
@@ -75,7 +75,7 @@ import org.springframework.stereotype.Component;
@Path("/v1/journalentries")
@Component
@Tag(name = "Journal Entries", description = """
A journal entry refers to the logging of transactions against general ledger accounts. A journal entry may consist of several line items, each of which is either a "debit" or a "credit". The total amount of the debits must equal the total amount of the credits or the journal entry is said to be "unbalanced"\s
A journal entry refers to the logging of transactions against general ledger accounts. A journal entry may consist of several line items, each of which is either a "debit" or a "credit". The total amount of the debits must equal the total amount of the credits or the journal entry is said to be "unbalanced"
A journal entry directly changes the account balances on the general ledger""")
@RequiredArgsConstructor
@@ -21,6 +21,7 @@ package org.apache.fineract.infrastructure.email.service;
import java.time.Year;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
public class EmailTemplateServiceImpl implements EmailTemplateService {
@@ -129,7 +130,7 @@ public class EmailTemplateServiceImpl implements EmailTemplateService {
+ escape(supportEmail) + "</a>"
+ " </p>"
+ " <p style=\"margin:0;font-size:12px;color:#94a3b8;\">"
+ " &copy; " + Year.now().getValue() + " " + escape(brandName) + ". All rights reserved."
+ " &copy; " + Year.now(DateUtils.getDateTimeZoneOfTenant()).getValue() + " " + escape(brandName) + ". All rights reserved."
+ " </p>"
+ " </td></tr>"
+ " </table>"
@@ -109,6 +109,7 @@ public class ImpsCryptoUtil {
}
/** Decrypt inbound payload using the CBS private key. */
@SuppressWarnings("StringSplitter")
public String decryptJson(String encryptedPayload) {
if (cbsPrivateKey == null) {
throw new IllegalStateException("CBS private key not loaded — check fineract.imps.cbs.private-key-path");
@@ -34,6 +34,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* STUB implementation — returns hard-coded SUCCESS for every request and
@@ -108,7 +109,7 @@ public class ImpsCbsServiceImpl implements ImpsCbsService {
resp.setRefId(request.getRefId());
resp.setUtr(request.getUtr());
resp.setAmount(request.getAmount());
resp.setTimeStamp(OffsetDateTime.now().format(ISO_OFFSET));
resp.setTimeStamp(OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(ISO_OFFSET));
resp.setStatus("SUCCESS");
resp.setResponseCode("00");
AccountData payeeAcc = Optional.ofNullable(request.getPayee()).map(PartyData::getAccount).orElse(null);
@@ -28,7 +28,6 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Credentials;
import okhttp3.MediaType;
@@ -42,6 +41,7 @@ import org.apache.fineract.infrastructure.openproject.data.OpenProjectProjectDat
import org.apache.fineract.infrastructure.openproject.data.OpenProjectStatusSummaryData;
import org.apache.fineract.infrastructure.openproject.data.OpenProjectWorkPackageData;
import org.springframework.stereotype.Service;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
@Slf4j
@@ -164,7 +164,7 @@ public class OpenProjectService {
}
// Set required custom field: "Last Follow-up Date" (customField8)
body.addProperty("customField8", LocalDate.now().toString());
body.addProperty("customField8", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
if (typeName != null && !typeName.isBlank()) {
JsonObject links = new JsonObject();
@@ -47,6 +47,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
@RequiredArgsConstructor
@@ -127,7 +128,7 @@ public class RbiFeedFetchService {
NodeList items = doc.getElementsByTagName("item");
log.debug("RBI FeedFetchService - Parsed {} items from RSS for category: {}", items.getLength(), category);
List<RbiFeedItem> newItems = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
for (int i = 0; i < items.getLength(); i++) {
Element item = (Element) items.item(i);
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.infrastructure.security.otp.api;
import com.google.common.base.Splitter;
import com.google.common.collect.Iterables;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
@@ -107,7 +109,7 @@ public class EmailLoginOtpApiResource {
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
return Iterables.get(Splitter.on(',').split(fwd), 0).trim();
}
return req.getRemoteAddr();
}
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.infrastructure.security.otp.api;
import com.google.common.base.Splitter;
import com.google.common.collect.Iterables;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
@@ -107,7 +109,7 @@ public class LoginOtpApiResource {
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
return Iterables.get(Splitter.on(',').split(fwd), 0).trim();
}
return req.getRemoteAddr();
}
@@ -45,6 +45,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
@@ -128,7 +129,7 @@ public class EmailLoginOtpServiceImpl implements EmailLoginOtpService {
// Render the branded template — same look and feel for OTP, welcome, password-reset, alerts.
final String html = emailTemplateService.renderOtp(recipientName, otp, validityMinutes);
final String subject = "Your OTP for Login - " + appName + " (" + LocalDateTime.now().format(SUBJECT_TIMESTAMP) + ")";
final String subject = "Your OTP for Login - " + appName + " (" + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(SUBJECT_TIMESTAMP) + ")";
final boolean sent = emailSenderService.sendHtml(email, subject, html);
if (sent) {
@@ -20,7 +20,6 @@ package org.apache.fineract.organisation.teller.service;
import jakarta.persistence.PersistenceException;
import java.util.Map;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
@@ -44,7 +43,6 @@ import org.apache.fineract.organisation.teller.domain.CashierTransactionReposito
import org.apache.fineract.organisation.teller.domain.CashierTxnType;
import org.apache.fineract.organisation.teller.domain.Teller;
import org.apache.fineract.organisation.teller.domain.TellerRepositoryWrapper;
import org.apache.fineract.organisation.teller.exception.CashierExistForTellerException;
import org.apache.fineract.organisation.teller.exception.CashierNotFoundException;
import org.apache.fineract.organisation.teller.exception.TellerAlreadyExistsException;
import org.apache.fineract.organisation.teller.serialization.TellerCommandFromApiJsonDeserializer;
@@ -33,6 +33,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashexchange")
@Component
@@ -94,7 +95,7 @@ public class CashExchangeApiResource {
header.setNote(note);
header.setCustomerIdProof(customerIdProof);
header.setIsReversed(false);
header.setCreatedAt(LocalDateTime.now());
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
@@ -107,7 +108,9 @@ public class CashExchangeApiResource {
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
header.setCashierId(cashierId);
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
}
} catch (Exception e) {
// resolving the current user must never block recording the exchange
@@ -160,14 +163,18 @@ public class CashExchangeApiResource {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
exchange.setIsReversed(true);
exchange.setReversedAt(LocalDateTime.now());
exchange.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
exchange.setReverseReason(reason);
try {
AppUser user = this.context.authenticatedUser();
if (user != null) exchange.setReversedBy(user.getId());
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
repository.save(exchange);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
@@ -59,6 +59,9 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import com.google.common.collect.Iterables;
import com.google.common.base.Splitter;
@Path("/v1/cashier/deposit-requests")
@Component
@@ -364,7 +367,7 @@ public class CashierDepositRequestApiResource {
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date");
final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null;
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
final String description = (String) record.get("description");
final String storedAccountType = (String) record.get("account_type");
@@ -513,7 +516,7 @@ public class CashierDepositRequestApiResource {
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date");
final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null;
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
final String description = (String) record.get("description");
if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) {
@@ -559,7 +562,7 @@ public class CashierDepositRequestApiResource {
final Long savingsAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
@@ -587,7 +590,7 @@ public class CashierDepositRequestApiResource {
final Long currentAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
@@ -644,7 +647,7 @@ public class CashierDepositRequestApiResource {
final Object paymentTypeId, final String txnDateStr, final String note,
final boolean isDeposit) {
final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
// Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra)
@@ -686,67 +689,6 @@ public class CashierDepositRequestApiResource {
return result.getTransactionId() != null ? result.getTransactionId() : null;
}
private void saveGlDenomination(final String denominationData,
final Map<?, ?> record, final String txnIdRaw) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
} catch (Exception e) {
return;
}
if (denomMap == null || denomMap.size() == 0) return;
// Convert hex transaction ID to Long (same logic as CashierGlDenominationApiResource)
Long txnId = null;
if (txnIdRaw != null && !txnIdRaw.isBlank()) {
try {
txnId = Long.valueOf(txnIdRaw);
} catch (NumberFormatException ignored) {
try {
final String hex = txnIdRaw.length() > 15 ? txnIdRaw.substring(txnIdRaw.length() - 15) : txnIdRaw;
txnId = Long.parseUnsignedLong(hex, 16);
} catch (NumberFormatException ignored2) {
txnId = null;
}
}
}
final String accountNo = (String) record.get("account_no");
final Object cashierIdObj = record.get("cashier_id");
final Long cashierId = cashierIdObj != null ? Long.valueOf(cashierIdObj.toString()) : null;
final String cashierUsername = (String) record.get("cashier_username");
for (final Map.Entry<String, com.google.gson.JsonElement> entry : denomMap.entrySet()) {
final int count;
try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; }
if (count <= 0) continue;
final String[] parts = entry.getKey().split(":", 2);
if (parts.length != 2) continue;
final String kind = parts[0];
final int value;
try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; }
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?"
+ " AND (deleted IS NULL OR deleted = false) LIMIT 1",
Long.class, value, kind);
if (ids.isEmpty()) continue;
final Long denomId = ids.get(0);
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, NULL, ?, NULL, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
""",
txnId, accountNo, denomId,
value, count, BigDecimal.valueOf((long) value * count),
cashierId, cashierUsername
);
}
}
/**
* Saves the denomination breakdown the cashier entered at submit time into
* m_savings_txn_denomination once the deposit is approved, so the cashier's
@@ -797,56 +739,6 @@ public class CashierDepositRequestApiResource {
}
}
private void saveDepositDenomination(final String denominationData,
final Map<?, ?> record, final Long txnId) {
final JsonObject denomMap;
try {
denomMap = gson.fromJson(denominationData, JsonObject.class);
} catch (Exception e) {
return; // malformed JSON — skip silently
}
if (denomMap == null || denomMap.size() == 0) return;
final Object saObj = record.get("savings_account_id");
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
final String accountNo = (String) record.get("account_no");
final Object clientIdObj = record.get("client_id");
final Long clientId = clientIdObj != null ? Long.valueOf(clientIdObj.toString()) : null;
final Object cashierIdObj = record.get("cashier_id");
final Long cashierId = cashierIdObj != null ? Long.valueOf(cashierIdObj.toString()) : null;
final String cashierUsername = (String) record.get("cashier_username");
for (final Map.Entry<String, JsonElement> entry : denomMap.entrySet()) {
final int count;
try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; }
if (count <= 0) continue;
final String[] parts = entry.getKey().split(":", 2); // "note:500" or "coin:1"
if (parts.length != 2) continue;
final String kind = parts[0];
final int value;
try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; }
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?"
+ " AND (deleted IS NULL OR deleted = false) LIMIT 1",
Long.class, value, kind);
if (ids.isEmpty()) continue;
final Long denomId = ids.get(0);
jdbcTemplate.update("""
INSERT INTO m_savings_txn_denomination
(txn_id, savings_account_id, account_no, client_id, denomination_id,
denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?)
""",
txnId, savingsAccountId, accountNo, clientId, denomId,
value, count, BigDecimal.valueOf((long) value * count),
cashierId, cashierUsername
);
}
}
/**
* If a cash denomination breakdown is provided, its total must exactly equal
* the deposit amount — prevents the recorded vault movement from drifting
@@ -946,7 +838,7 @@ public class CashierDepositRequestApiResource {
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
} catch (Exception e) {
return LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
}
}
@@ -31,6 +31,7 @@ import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRep
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashintercashier")
@Component
@@ -69,7 +70,7 @@ public class CashInterCashierApiResource {
}
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now();
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
? json.get("fromParty").getAsString() : null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
@@ -95,13 +96,15 @@ public class CashInterCashierApiResource {
header.setToParty(toParty);
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
header.setIsReversed(false);
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
CashInterCashier saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), denominations);
@@ -128,13 +131,17 @@ public class CashInterCashierApiResource {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {}
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
// intentionally ignored
}
record.setIsReversed(true);
record.setReversedAt(LocalDateTime.now());
record.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
record.setReverseReason(reason);
if (user != null) record.setReversedBy(user.getId());
repository.save(record);
@@ -32,6 +32,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashreconciliation")
@Component
@@ -62,7 +63,7 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
@@ -187,7 +188,7 @@ public class CashReconciliationApiResource {
@Path("expected/me")
public String getExpectedForMe(@QueryParam("asOfDate") String asOfDate) {
context.authenticatedUser().validateHasPermissionTo("READ_CASHRECONCILIATION");
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
Long appUserId;
try {
appUserId = context.authenticatedUser().getId();
@@ -223,7 +224,7 @@ public class CashReconciliationApiResource {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)",
Integer.class, cashierId, reconDate);
@@ -247,7 +248,7 @@ public class CashReconciliationApiResource {
"cashierId is required.");
}
final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull()
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now();
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull()
? json.get("cashierName").getAsString() : null;
final Long tellerId = json.has("tellerId") && !json.get("tellerId").isJsonNull()
@@ -299,12 +300,14 @@ public class CashReconciliationApiResource {
header.setVariance(variance);
header.setStatus(status);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
CashReconciliation saved = repository.saveAndFlush(header);
for (CashReconciliationDetail d : details) {
@@ -18,6 +18,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashreports")
@Component
@@ -34,7 +35,7 @@ public class CashReportApiResource {
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
@@ -238,7 +239,7 @@ public class CashReportApiResource {
return gson.toJson(result);
}
final String today = LocalDate.now().toString();
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// Find the earliest transfer date before today for which no settlement exists on or after that date
final String sql = "SELECT MIN(ct.transfer_date) AS earliest_unsettled "
@@ -256,7 +257,9 @@ public class CashReportApiResource {
java.sql.Date earliestDate = null;
try {
earliestDate = jdbcTemplate.queryForObject(sql, java.sql.Date.class, cashierId, today, cashierId);
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
final boolean unsettled = earliestDate != null;
final String unsettledSince = earliestDate != null ? earliestDate.toString() : null;
@@ -265,7 +268,9 @@ public class CashReportApiResource {
if (unsettled) {
try {
pendingAmount = denominationBreakdownWriter.getCashierTotalCash(cashierId);
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
}
// Check whether today's morning cash transfer has been received
@@ -276,7 +281,9 @@ public class CashReportApiResource {
Integer todayTransferCount = 0;
try {
todayTransferCount = jdbcTemplate.queryForObject(morningTransferSql, Integer.class, cashierId, today);
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
final boolean morningTransferReceived = todayTransferCount != null && todayTransferCount > 0;
result.put("cashierId", cashierId);
@@ -295,7 +302,7 @@ public class CashReportApiResource {
@Path("cashier-position")
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
// CASHIER role: always scope to their own position only
final Long scopedId = cashierRoleHelper.getCashierIdForFiltering();
@@ -326,7 +333,7 @@ public class CashReportApiResource {
@GET
@Path("cashier-position/me")
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
Long appUserId;
try {
appUserId = context.authenticatedUser().getId();
@@ -362,7 +369,7 @@ public class CashReportApiResource {
@Path("cashier-morning")
public String cashierMorning(@QueryParam("cashierId") Long cashierId,
@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
if (cashierId == null) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("cashierId", null);
@@ -515,7 +522,9 @@ public class CashReportApiResource {
cashierName = jdbcTemplate.queryForObject(
"SELECT ms.display_name FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id WHERE mc.id = ?",
String.class, cashierId);
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("cashierId", cashierId);
@@ -562,7 +571,7 @@ public class CashReportApiResource {
@QueryParam("offset") Integer offset,
@QueryParam("limit") Integer limit) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final int resolvedLimit = (limit != null && limit > 0) ? limit : 20;
final int resolvedOffset = (offset != null && offset >= 0) ? offset : 0;
@@ -36,6 +36,7 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashsettlement")
@Component
@@ -70,7 +71,7 @@ public class CashSettlementApiResource {
}
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now();
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
? json.get("fromParty").getAsString() : null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
@@ -116,13 +117,15 @@ public class CashSettlementApiResource {
header.setToParty(toParty);
header.setSettlementAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
header.setIsReversed(false);
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
// Commit header + details first, then GL dispatch separately to avoid
// EclipseLink flushing pending detail rows inside Fineract's GL transaction.
@@ -142,7 +145,9 @@ public class CashSettlementApiResource {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, fromCashierId);
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.settleCashFromCashier(glTellerId, fromCashierId, total,
currencyCode, settlementDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
note != null ? note : "");
@@ -174,13 +179,17 @@ public class CashSettlementApiResource {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {}
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {
// intentionally ignored
}
settlement.setIsReversed(true);
settlement.setReversedAt(LocalDateTime.now());
settlement.setReversedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
settlement.setReverseReason(reason);
if (user != null) settlement.setReversedBy(user.getId());
repository.save(settlement);
@@ -192,12 +201,16 @@ public class CashSettlementApiResource {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, settlement.getFromCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.allocateCashToCashier(glTellerId,
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
}
JsonObject result = new JsonObject();
@@ -214,7 +227,7 @@ public class CashSettlementApiResource {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required",
"cashierId query param is required.");
}
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
Integer.class, cashierId, settlementDate);
@@ -36,6 +36,7 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/cashtransfer")
@Component
@@ -105,7 +106,7 @@ public class CashTransferApiResource {
header.setToCashierId(toCashierId);
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
@@ -136,8 +137,10 @@ public class CashTransferApiResource {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, toCashierId);
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {}
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now();
} catch (Exception ignored) {
// intentionally ignored
}
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total,
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
note != null ? note : "");
@@ -169,15 +172,19 @@ public class CashTransferApiResource {
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
try {
AppUser user = this.context.authenticatedUser();
transfer.setReversedBy(user.getId());
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
transfer.setIsReversed(true);
transfer.setReversedAt(java.time.LocalDateTime.now());
transfer.setReversedAt(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
transfer.setReverseReason(reason);
repository.save(transfer);
@@ -188,12 +195,16 @@ public class CashTransferApiResource {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, transfer.getToCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
glDispatchService.settleCashFromCashier(glTellerId,
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
java.time.LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
java.time.LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {}
} catch (Exception ignored) {
// intentionally ignored
}
}
JsonObject result = new JsonObject();
@@ -38,6 +38,7 @@ import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenomin
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Records cashier denomination breakdown for GL account transactions (journal entries).
@@ -90,7 +91,7 @@ public class CashierGlDenominationApiResource {
final String txnDateStr = fromJsonHelper.extractStringNamed("txnDate", root);
final LocalDate txnDate = (txnDateStr != null && !txnDateStr.isBlank())
? LocalDate.parse(txnDateStr)
: LocalDate.now();
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
if (!fromJsonHelper.parameterExists("denominationBreakdown", root)) {
return "{\"result\":\"skipped\"}";
@@ -109,7 +110,9 @@ public class CashierGlDenominationApiResource {
officerId = user.getId();
officerUsername = user.getUsername();
}
} catch (final Exception ignored) {}
} catch (final Exception ignored) {
// intentionally ignored
}
final String resolvedTxnType = (txnType != null && !txnType.isBlank())
? txnType.toUpperCase()
@@ -144,7 +147,7 @@ public class CashierGlDenominationApiResource {
.count(count)
.totalAmount(lineTotal)
.txnDate(txnDate)
.createdAt(LocalDateTime.now())
.createdAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()))
.txnType(resolvedTxnType)
.officerId(officerId)
.officerUsername(officerUsername)
@@ -29,6 +29,7 @@ import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Cashier Share Collection API — POST /v1/cashier/sharecollection
@@ -131,7 +132,7 @@ public class ShareCollectionApiResource {
final LocalDate entryDate;
try {
entryDate = transactionDateStr != null
? LocalDate.parse(transactionDateStr) : LocalDate.now();
? LocalDate.parse(transactionDateStr) : LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
} catch (DateTimeParseException e) {
throw new PlatformDataIntegrityException(
"error.msg.sharecollection.date.invalid",
@@ -193,7 +194,7 @@ public class ShareCollectionApiResource {
final String txnId = "SC-" + shareAccountId + "-" + System.currentTimeMillis();
final long userId = currentUser.getId();
final String collectorName = currentUser.getUsername();
final LocalDateTime now = LocalDateTime.now();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
// ── Post GL journal entries ────────────────────────────────────────
// Dr Cash at Vault (SHARES_REFERENCE) = totalAmount
@@ -41,7 +41,6 @@ import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSer
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintStatsData;
import org.apache.fineract.portfolio.cfcfrms.data.ComplaintTicketData;
import org.apache.fineract.portfolio.cfcfrms.data.TimelineEventData;
import org.apache.fineract.portfolio.cfcfrms.service.CfcfrmsComplaintService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -56,7 +56,7 @@ import org.springframework.stereotype.Component;
* POST /v1/simulator/cfcfrms/verify-account – Verify account at bank
* POST /v1/simulator/cfcfrms/verify-transaction – Verify transaction ref
*
* --- STATUS & REPORTING ---
* --- STATUS &amp; REPORTING ---
* GET /v1/simulator/cfcfrms/complaint-status/{ref} – Complaint status from CFCFRMS side
* GET /v1/simulator/cfcfrms/bank-performance – Bank performance metrics
* </pre>
@@ -36,6 +36,7 @@ import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Seeds CFCFRMS complaint tickets, audit logs, and simulator logs on startup.
@@ -59,7 +60,7 @@ public class CfcfrmsDataSeeder {
}
LOG.info("Seeding CFCFRMS complaint data...");
final OffsetDateTime now = OffsetDateTime.now();
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
// ─── 3 RECEIVED ──────────────────────────────────────────────
seedTicket("TKT-20250318-001", "Rajesh Kumar Sharma", "9876543210",
@@ -46,6 +46,7 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
@RequiredArgsConstructor
@@ -206,7 +207,7 @@ public class CfcfrmsComplaintServiceImpl implements CfcfrmsComplaintService {
final Map<String, Object> result = new HashMap<>();
result.put("escalatedTo", nextIfsc);
result.put("escalationRef", escalationRef);
result.put("timestamp", OffsetDateTime.now().toString());
result.put("timestamp", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).toString());
return result;
}
@@ -218,7 +219,7 @@ public class CfcfrmsComplaintServiceImpl implements CfcfrmsComplaintService {
}
private String generateTicketId() {
final String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
final String date = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyyMMdd"));
final int seq = (int) (ticketRepository.count() + 1);
return String.format("TKT-%s-%03d", date, seq);
}
@@ -25,12 +25,10 @@ import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.cfcfrms.data.CfcfrmsConstants;
@@ -44,6 +42,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
@RequiredArgsConstructor
@@ -76,7 +75,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService {
final Map<String, Object> response = new LinkedHashMap<>();
response.put("cfcfrmsRef", cfcfrmsRef);
response.put("ticketId", ticketData.getTicketId());
response.put("pushedToBankAt", OffsetDateTime.now().toString());
response.put("pushedToBankAt", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).toString());
response.put("bankAcknowledged", true);
response.put("message", "Complaint pushed to " + CfcfrmsConstants.BANK_IFSC + " successfully");
return response;
@@ -85,7 +84,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService {
@Override
public Map<String, Object> pushHoldDirective(final JsonObject json) {
final String cfcfrmsRef = getStringOrThrow(json, "cfcfrmsRef");
final String directiveRef = "DIR-" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
final String directiveRef = "DIR-" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyyMMdd"))
+ "-" + String.format("%04d", RANDOM.nextInt(10000));
final String directiveType = getStringOrThrow(json, "directiveType");
@@ -112,7 +111,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService {
response.put("directiveRef", directiveRef);
response.put("status", "DIRECTIVE_SENT");
response.put("bankResponse", bankResponse);
response.put("timestamp", OffsetDateTime.now().toString());
response.put("timestamp", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).toString());
return response;
}
@@ -208,7 +207,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService {
public Map<String, Object> receiveEscalation(final JsonObject json) {
final String cfcfrmsRef = getStringOrThrow(json, "cfcfrmsRef");
final String ticketId = getStringOrThrow(json, "ticketId");
final String escalationRef = "ESC-" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MMdd"))
final String escalationRef = "ESC-" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyy-MMdd"))
+ "-" + String.format("%04d", RANDOM.nextInt(10000));
// Extract nextBank from fundsMovedTo
@@ -268,7 +267,7 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService {
final Map<String, Object> response = new LinkedHashMap<>();
response.put("transactionFound", true);
response.put("transactionStatus", "COMPLETED");
response.put("transactionTimestamp", OffsetDateTime.now().minusHours(2).toString());
response.put("transactionTimestamp", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).minusHours(2).toString());
response.put("senderAccount", json.has("accountNumber") ? json.get("accountNumber").getAsString() : "UNKNOWN");
response.put("receiverAccount", json.has("accountNumber") ? json.get("accountNumber").getAsString() : "UNKNOWN");
@@ -369,14 +368,14 @@ public class CfcfrmsSimulatorServiceImpl implements CfcfrmsSimulatorService {
.setScale(1, java.math.RoundingMode.HALF_UP) + "%"
: "0.0%";
response.put("tatComplianceRate", tatCompliance);
response.put("lastActivityAt", OffsetDateTime.now().toString());
response.put("lastActivityAt", OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant()).toString());
return response;
}
// ── Helpers ──
private String generateCfcfrmsRef() {
final String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MMdd"));
final String date = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyy-MMdd"));
return "CFCFRMS-" + date + "-" + String.format("%04d", RANDOM.nextInt(10000));
}
@@ -201,7 +201,7 @@ public class NpciInwardPushService {
final String presentingBranchCode = getString(instrument, "depositBranchCode");
item.recordInwardMeta(getString(instrument, "serialNumber"), presentingBranchCode, batch.getReturnTatDeadline());
final Boolean instrumentStopPaymentFlag = instrument.has("stopPaymentFlag") && !instrument.get("stopPaymentFlag").isJsonNull()
final Boolean instrumentStopPaymentFlag = (instrument.has("stopPaymentFlag") && !instrument.get("stopPaymentFlag").isJsonNull())
? instrument.get("stopPaymentFlag").getAsBoolean()
: null;
item.recordSourceDetails(getString(instrument, "ctsReferenceNumber"), instrumentStopPaymentFlag,
@@ -70,6 +70,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Path("/v1/clerk/disbursement-requests")
@Component
@@ -383,7 +384,7 @@ public class ClerkDisbursementRequestApiResource {
final JsonArray creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class);
final Object ptId = record.get("payment_type_id");
final String description = (String) record.get("description");
final String txnDateStr = LocalDate.now().toString();
final String txnDateStr = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final Long sourceGlId = record.get("gl_account_id") != null
? ((Number) record.get("gl_account_id")).longValue() : null;
@@ -556,7 +557,7 @@ public class ClerkDisbursementRequestApiResource {
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
final Long entryPtId = entry.has("paymentTypeId") && !entry.get("paymentTypeId").isJsonNull()
? entry.get("paymentTypeId").getAsLong()
? Long.valueOf(entry.get("paymentTypeId").getAsLong())
: (ptId != null ? ((Number) ptId).longValue() : null);
if ("GL".equals(entryType)) {
@@ -810,7 +811,7 @@ public class ClerkDisbursementRequestApiResource {
? req.get("currencyCode").getAsString() : "INR";
final String txnDateStr = req.has("transactionDate") && !req.get("transactionDate").isJsonNull()
? req.get("transactionDate").getAsString()
: LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ISO_LOCAL_DATE);
final String description = req.has("description") && !req.get("description").isJsonNull()
? req.get("description").getAsString() : null;
@@ -830,7 +831,6 @@ public class ClerkDisbursementRequestApiResource {
// Validate all entries: savings/current only (no GL), savingsAccountId present, amount > 0
final Set<Long> drAccountIds = new LinkedHashSet<>();
final Set<Long> crAccountIds = new LinkedHashSet<>();
for (final JsonObject e : drEntries) {
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
@@ -858,7 +858,6 @@ public class ClerkDisbursementRequestApiResource {
if (drAccountIds.contains(crId)) throw apiError(400,
"Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId)
+ " cannot appear on both DR and CR sides of the same transfer.");
crAccountIds.add(crId);
}
// Validate balanced entry: total DR == total CR
@@ -999,7 +998,7 @@ public class ClerkDisbursementRequestApiResource {
* under concurrent inserts on the same date.
*/
private String generateBtVoucherNo(final long logId) {
return "BT" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd"))
return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
+ String.format("%05d", logId);
}
@@ -1053,7 +1052,9 @@ public class ClerkDisbursementRequestApiResource {
try {
final Long txnIdLong = Long.parseLong(txnId);
undoOps.add(() -> undoAccountTransaction(accountId, txnIdLong, isCurrentAccount));
} catch (NumberFormatException ignored) {}
} catch (NumberFormatException ignored) {
// intentionally ignored
}
}
private void undoAccountTransaction(final Long accountId, final Long transactionId,
@@ -1072,50 +1073,6 @@ public class ClerkDisbursementRequestApiResource {
commandsSourceWritePlatformService.logCommandSource(cmd);
}
private String executeSavingsDeposit(
final Long savingsAccountId, final BigDecimal amount,
final Long paymentTypeId, final String txnDateStr, final String note) {
final String date = formatForFineract(txnDateStr);
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) payload.addProperty("paymentTypeId", paymentTypeId);
if (note != null && !note.isBlank()) payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder()
.savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeCurrentAccountDeposit(
final Long currentAccountId, final BigDecimal amount,
final Long paymentTypeId, final String txnDateStr, final String note) {
final String date = formatForFineract(txnDateStr);
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) payload.addProperty("paymentTypeId", paymentTypeId);
if (note != null && !note.isBlank()) payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder()
.currentAccountDeposit(currentAccountId)
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private boolean isCurrentAccountById(final Long accountId) {
try {
final Integer count = jdbcTemplate.queryForObject(
@@ -1131,7 +1088,7 @@ public class ClerkDisbursementRequestApiResource {
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
} catch (Exception e) {
return LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
}
}
@@ -1380,7 +1337,7 @@ public class ClerkDisbursementRequestApiResource {
? (BigDecimal) accts.get(0).get("account_balance_derived") : BigDecimal.ZERO;
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final OffsetDateTime now = OffsetDateTime.now();
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
final Long userId = currentUser.getId();
@@ -1534,7 +1491,7 @@ public class ClerkDisbursementRequestApiResource {
+ ". Available: " + currentBalance + ", Requested: " + amount);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final OffsetDateTime now = OffsetDateTime.now();
final OffsetDateTime now = OffsetDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
final Long userId = currentUser.getId();
@@ -59,6 +59,9 @@ import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import com.google.common.collect.Iterables;
import com.google.common.base.Splitter;
@Path("/v1/clerk/withdrawal-requests")
@Component
@@ -437,7 +440,7 @@ public class ClerkWithdrawalRequestApiResource {
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date");
final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null;
final String txnDateStr = txnDateObj != null ? Iterables.get(Splitter.on(' ').split(txnDateObj.toString()), 0) : null;
final String description = (String) record.get("description");
String txnId = null;
@@ -713,7 +716,7 @@ public class ClerkWithdrawalRequestApiResource {
final Long savingsAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
@@ -741,7 +744,7 @@ public class ClerkWithdrawalRequestApiResource {
final Long currentAccountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
@@ -780,7 +783,7 @@ public class ClerkWithdrawalRequestApiResource {
final LocalDate ld = LocalDate.parse(isoDate, DateTimeFormatter.ISO_LOCAL_DATE);
return ld.format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
} catch (Exception e) {
return LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
}
}
@@ -0,0 +1,164 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.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.DefaultValue;
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.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.UriInfo;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clients")
@Component
@Tag(name = "Client Edit Approval",
description = "Maker-checker workflow for editing an existing Client's core profile details. Edits are held in "
+ "m_client_pending_edit (a pending row there is itself the lock) and only applied to the real client once a "
+ "different user (the checker) approves — see /clients/{clientId}/edit/approve and /reject below.")
@RequiredArgsConstructor
public class ClientEditApiResource {
private final PlatformSecurityContext context;
private final ClientEditWritePlatformService clientEditWritePlatformService;
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final ApiRequestParameterHelper apiRequestParameterHelper;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<ClientPendingEditData> pendingEditApiJsonSerializer;
@POST
@Path("{clientId}/edit")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Submit a details edit for a Client",
description = "Stages the proposed changes in m_client_pending_edit instead of applying them immediately — see "
+ "/clients/{clientId}/edit/approve and /reject below.")
public String submitEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_CLIENT");
final JsonCommand command = JsonCommand.fromJsonElement(clientId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.clientEditWritePlatformService.submitEditForApproval(clientId, command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Client details edits pending approval",
description = "Supports status/clientName/fromDate/toDate filtering (fromDate/toDate as yyyy-MM-dd, inclusive, filtered "
+ "on submitted date; clientName is a case-insensitive substring match) plus offset/limit pagination — mirrors "
+ "/chequebook/pending-requests.")
public Page<ClientPendingEditData> retrieveAllPendingEdits(@QueryParam("status") final String status,
@QueryParam("clientName") final String clientName, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate, @QueryParam("offset") @DefaultValue("0") final Integer offset,
@QueryParam("limit") @DefaultValue("20") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final int safeLimit = Math.min(limit, 200);
return this.clientPendingEditReadPlatformService.retrieveAllPendingEdits(status, clientName, fromDate, toDate, offset, safeLimit);
}
@GET
@Path("{clientId}/edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending details edit for a Client, if any")
public String retrievePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final ClientPendingEditData pendingEdit = this.clientPendingEditReadPlatformService.retrievePendingEdit(clientId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{clientId}/edit/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Client details edit",
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
throw new NoAuthorizationException("User has no authority to approve client edits");
}
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{clientId}/edit/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Client details edit",
description = "Discards the proposed changes without applying them. The maker of the edit cannot also reject it — no exceptions.")
public String rejectPendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_CLIENT")) {
throw new NoAuthorizationException("User has no authority to reject client edits");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.clientEditWritePlatformService.rejectEdit(clientId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -39,6 +39,8 @@ import jakarta.ws.rs.core.MediaType;
import java.io.InputStream;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import java.time.LocalDate;
import java.time.Period;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -158,7 +160,13 @@ public class ClientKycApiResource {
template.put("clientTypeName", clientTypeName);
template.put("legalFormId", legalFormId);
template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId));
// Minor detection mirrors the frontend's own rule (age < 18) so the document list
// served here stays consistent with what the UI computes from the same date of birth.
final LocalDate dateOfBirth = client.dateOfBirth();
final boolean isMinor = dateOfBirth != null && Period.between(dateOfBirth, LocalDate.now()).getYears() < 18;
template.put("isMinor", isMinor);
template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor));
return toApiJsonSerializer.serialize(template);
}
@@ -166,11 +174,21 @@ public class ClientKycApiResource {
/**
* Returns required KYC documents based on client type (RBI Master Direction on KYC).
*/
private List<Map<String, Object>> getRequiredDocuments(final String clientTypeName, final Integer legalFormId) {
private List<Map<String, Object>> getRequiredDocuments(final String clientTypeName, final Integer legalFormId,
final boolean isMinor) {
final List<Map<String, Object>> docs = new ArrayList<>();
// Individual types (legalFormId=1)
if (legalFormId != null && legalFormId == 1) {
if (isMinor) {
// Minors cannot hold PAN, Voter ID, Driving Licence or an NREGA job card — RBI
// accepts Aadhaar, Passport, Birth Certificate or School ID Card instead.
docs.add(doc("Aadhaar Card", "AADHAAR", true));
docs.add(doc("Birth Certificate", "BIRTH_CERTIFICATE", false));
docs.add(doc("School ID Card", "SCHOOL_ID_CARD", false));
docs.add(doc("Passport", "PASSPORT", false));
return docs;
}
docs.add(doc("Aadhaar Card", "AADHAAR", true));
docs.add(doc("PAN Card", "PAN", true));
docs.add(doc("Voter ID", "VOTER_ID", false));
@@ -44,6 +44,7 @@ import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.data.AuditData;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.AuditReadPlatformService;
@@ -142,7 +143,8 @@ public class ClientsApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Clients", description = "The list capability of clients can support pagination and sorting.\n\n"
+ "Example Requests:\n" + "\n" + "clients\n" + "\n" + "clients?fields=displayName,officeName,timeline\n" + "\n"
+ "clients?offset=10&limit=50\n" + "\n" + "clients?orderBy=displayName&sortOrder=DESC")
+ "clients?offset=10&limit=50\n" + "\n" + "clients?orderBy=displayName&sortOrder=DESC\n\n"
+ "clients?fromDate=2026-01-01&toDate=2026-01-31 (submittedon_date range, yyyy-MM-dd, inclusive)")
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ClientsApiResourceSwagger.GetClientsResponse.class)))
public String retrieveAll(@Context final UriInfo uriInfo,
@QueryParam("officeId") @Parameter(description = "officeId") final Long officeId,
@@ -157,10 +159,15 @@ public class ClientsApiResource {
@QueryParam("orderBy") @Parameter(description = "orderBy") final String orderBy,
@QueryParam("sortOrder") @Parameter(description = "sortOrder") final String sortOrder,
@QueryParam("orphansOnly") @Parameter(description = "orphansOnly") final Boolean orphansOnly,
@QueryParam("legalForm") final Integer legalForm) {
@QueryParam("legalForm") final Integer legalForm,
@QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate,
@QueryParam("toDate") @Parameter(description = "toDate") final String toDate) {
final LocalDate fromDateParsed = StringUtils.isNotBlank(fromDate) ? LocalDate.parse(fromDate) : null;
final LocalDate toDateParsed = StringUtils.isNotBlank(toDate) ? LocalDate.parse(toDate) : null;
return retrieveAll(uriInfo, officeId, externalId, displayName, firstname, lastname, status, legalForm, hierarchy, offset, limit,
orderBy, sortOrder, orphansOnly, false);
orderBy, sortOrder, orphansOnly, false, fromDateParsed, toDateParsed);
}
@GET
@@ -437,7 +444,7 @@ public class ClientsApiResource {
public String retrieveAll(final UriInfo uriInfo, final Long officeId, final String externalId, final String displayName,
final String firstname, final String lastname, final String status, final Integer legalForm, final String hierarchy,
final Integer offset, final Integer limit, final String orderBy, final String sortOrder, final Boolean orphansOnly,
final boolean isSelfUser) {
final boolean isSelfUser, final LocalDate fromDate, final LocalDate toDate) {
context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
sqlValidator.validate(orderBy);
sqlValidator.validate(sortOrder);
@@ -445,7 +452,8 @@ public class ClientsApiResource {
sqlValidator.validate(hierarchy);
final SearchParameters searchParameters = SearchParameters.builder().limit(limit).officeId(officeId).externalId(externalId)
.name(displayName).hierarchy(hierarchy).firstname(firstname).lastname(lastname).status(status).orphansOnly(orphansOnly)
.isSelfUser(isSelfUser).offset(offset).orderBy(orderBy).sortOrder(sortOrder).legalForm(legalForm).build();
.isSelfUser(isSelfUser).offset(offset).orderBy(orderBy).sortOrder(sortOrder).legalForm(legalForm).fromDate(fromDate)
.toDate(toDate).build();
final Page<ClientData> clientData = clientReadPlatformService.retrieveAll(searchParameters);
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return toApiJsonSerializer.serialize(settings, clientData, ClientApiConstants.CLIENT_RESPONSE_DATA_PARAMETERS);
@@ -0,0 +1,55 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/kyc/risk-rules")
@Component
@Tag(name = "KYC Risk Rules", description = "Retrieve database-driven master rules for client AML/CFT risk rating calculation.")
@RequiredArgsConstructor
public class KycRiskRulesApiResource {
private final PlatformSecurityContext context;
private final KycRiskRulesReadPlatformService readPlatformService;
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve KYC risk rules", description = "Returns list of database-defined risk rules ordered by precedence.")
public String retrieveRiskRules() {
this.context.authenticatedUser();
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
return this.toApiJsonSerializer.serialize(rules);
}
}
@@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicLong;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.springframework.stereotype.Service;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Dummy / stub implementation of {@link CkycSftpUploadService}.
@@ -60,7 +61,7 @@ public class DummyCkycSftpUploadServiceImpl implements CkycSftpUploadService {
final String ckycNumber = generateCkycNumber(kyc.getClient().getId());
final String batchId = generateBatchId();
final LocalDate today = LocalDate.now();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
return CkycUploadResult.builder()
.ckycNumber(ckycNumber)
@@ -82,7 +83,7 @@ public class DummyCkycSftpUploadServiceImpl implements CkycSftpUploadService {
return CkycUploadResult.builder()
.status("FAILED")
.remarks("Uploaded file is not a valid ZIP archive (bad magic bytes).")
.uploadedOn(LocalDate.now())
.uploadedOn(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()))
.build();
}
@@ -92,7 +93,7 @@ public class DummyCkycSftpUploadServiceImpl implements CkycSftpUploadService {
return CkycUploadResult.builder()
.ckycNumber(ckycNumber)
.batchId(batchId)
.uploadedOn(LocalDate.now())
.uploadedOn(LocalDate.now(DateUtils.getDateTimeZoneOfTenant()))
.status("SUCCESS")
.remarks("Dummy CKYC ZIP upload accepted (" + zipBytes.length + " bytes). "
+ "Replace with real CERSAI SFTP integration before go-live.")
@@ -112,7 +113,7 @@ public class DummyCkycSftpUploadServiceImpl implements CkycSftpUploadService {
}
private String generateBatchId() {
return "DUMMYCKYC-" + BATCH_ID_FORMAT.format(LocalDate.now()) + "-"
return "DUMMYCKYC-" + BATCH_ID_FORMAT.format(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())) + "-"
+ String.format("%06d", BATCH_COUNTER.getAndIncrement());
}
}
@@ -32,7 +32,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor;
@@ -50,6 +49,7 @@ import org.apache.fineract.portfolio.client.ckyc.domain.CkycBatchRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Slf4j
@Service
@@ -61,16 +61,22 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
private static final DateTimeFormatter ZIP_NAME_TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss");
/**
* Names of code values (from {@code KYCAddressProofType}) that unambiguously indicate an
* address proof. Aadhaar / Passport / DL / Voter ID can be both ID and address proof — we
* default them to ID (PI) unless the UI tags them explicitly.
* Code (m_code.code_name) under which an identifier's document type must be classified for it
* to count as an address proof. Matches the same m_code_value config that
* KycProofTypesApiResource (GET /kyc/proof-types) reads to populate the CKYC tab's
* "Address Proof Document" card — e.g. Aadhaar Card is seeded as a document type under BOTH
* KYCIdProofType and KYCAddressProofType (two separate m_client_identifier rows), so trusting
* the code a given identifier was actually tagged under is the only way this can agree with
* what the CKYC tab already shows as "available".
*/
private static final Set<String> ADDRESS_PROOF_ONLY = Set.of(
"UTILITY BILLS", "UTILITY BILL", "ELECTRICITY BILL", "WATER BILL", "GAS BILL", "INTERNET BILL",
"BANK ACCOUNT STATEMENT / PASSBOOK", "BANK STATEMENT", "POSTPAID MOBILE BILL",
"REGISTERED RENT AGREEMENT", "RENT AGREEMENT", "PROPERTY TAX RECEIPT",
"EMPLOYER LETTER (FOR SALARIED EMPLOYEES)", "EMPLOYER LETTER",
"PENSION PAYMENT ORDERS (FOR RETIREES)", "PENSION PAYMENT ORDER");
private static final String ADDRESS_PROOF_CODE_NAME = "KYCAddressProofType";
// Top-level ZIP folders — one per artefact type, so every client's file of a given type lands
// in the same folder instead of all 4 types being interleaved flat at the ZIP root.
private static final String PHOTO_FOLDER = "PHOTO/";
private static final String SIGNATURE_FOLDER = "SIGNATURE/";
private static final String ID_PROOF_FOLDER = "ID_PROOF/";
private static final String ADDRESS_PROOF_FOLDER = "ADDRESS_PROOF/";
private final JdbcTemplate jdbcTemplate;
private final DocumentReadPlatformService documentReadPlatformService;
@@ -86,11 +92,11 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
throw new IllegalArgumentException("clientIds must not be empty");
}
final LocalDate today = LocalDate.now();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final int seq = nextBatchSequenceForToday(today);
final String batchId = "BATCH-" + BATCH_ID_FMT.format(today) + "-" + String.format("%03d", seq);
// ZIP filename: "CERSAI-ZIP-<yyyy-MM-dd-HH-mm-ss>.zip" — hyphen-separated per spec.
final String filename = "CERSAI-ZIP-" + ZIP_NAME_TS_FMT.format(java.time.LocalDateTime.now()) + ".zip";
final String filename = "CERSAI-ZIP-" + ZIP_NAME_TS_FMT.format(java.time.LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())) + ".zip";
final List<CkycBatchManifestEntry> manifest = new ArrayList<>();
final ByteArrayOutputStream zipBytesOut = new ByteArrayOutputStream();
@@ -111,13 +117,30 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
final byte[] zipBytes = zipBytesOut.toByteArray();
final String checksum = "sha256:" + sha256Hex(zipBytes);
// Persist the ZIP as an m_document row (parent_entity_type='ckyc_batch', parent_entity_id=seq)
// Persistence happens BEFORE we know m_ckyc_batch.id, so we use the synthetic seq as a
// temporary parent_entity_id. The real FK is via m_ckyc_batch.document_id after save.
// Persist the batch registry row FIRST (without documentId) so we have its real,
// DB-generated id before creating the ZIP's m_document row. The document's
// parent_entity_id must be the actual m_ckyc_batch.id — using the daily `seq` here
// (as a stand-in for an id that doesn't exist yet) was wrong: seq resets every day
// while m_ckyc_batch.id is a global auto-increment, so GET
// /ckyc_batch/{id}/documents/{documentId}/attachment (which matches on the REAL batch
// id) 404'd for any batch whose seq didn't coincidentally equal its DB id.
final CkycBatch batch = new CkycBatch();
batch.setBatchId(batchId);
batch.setFilename(filename);
batch.setChecksumSha256(checksum);
batch.setRecordCount(includedCount);
batch.setSizeBytes((long) zipBytes.length);
batch.setCreatedOnDate(today);
batch.setUploadedToCersai(false);
batch.setManifestJson(new Gson().toJson(manifest));
final CkycBatch saved = ckycBatchRepository.saveAndFlush(batch);
// Now persist the ZIP as an m_document row keyed to the batch's real id.
final var docResponse = documentWritePlatformService.createDocument(
DocumentCreateRequest.builder()
.entityType("ckyc_batch")
.entityId((long) seq)
.entityId(saved.getId())
.name(batchId)
.fileName(filename)
.description(StringUtils.defaultIfEmpty(remarks, "CERSAI CKYC batch ZIP"))
@@ -126,19 +149,8 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
.stream(new ByteArrayInputStream(zipBytes))
.build());
// Persist the batch registry row
final CkycBatch batch = new CkycBatch();
batch.setBatchId(batchId);
batch.setFilename(filename);
batch.setChecksumSha256(checksum);
batch.setRecordCount(includedCount);
batch.setSizeBytes((long) zipBytes.length);
batch.setDocumentId(docResponse.getResourceId() != null ? docResponse.getResourceId().longValue() : null);
batch.setCreatedOnDate(today);
batch.setUploadedToCersai(false);
batch.setManifestJson(new Gson().toJson(manifest));
final CkycBatch saved = ckycBatchRepository.saveAndFlush(batch);
saved.setDocumentId(docResponse.getResourceId() != null ? docResponse.getResourceId().longValue() : null);
ckycBatchRepository.saveAndFlush(saved);
log.info("CKYC batch created: id={}, batchId={}, filename={}, recordCount={}/{}, bytes={}",
saved.getId(), batchId, filename, includedCount, clientIds.size(), zipBytes.length);
@@ -184,32 +196,40 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
entry.setCifNumber(cifNumber);
entry.setDisplayName(displayName);
// Photo — packaged as "<CIF>-PI-PHOTO.<ext>" using m_client.image_id → m_image
// Photo — packaged as "PHOTO/<CIF>-PI-PHOTO.<ext>" using m_client.image_id → m_image.
// Every artefact type gets its own top-level ZIP folder, so a batch of N clients produces
// one PHOTO/ folder containing all N client photos (and likewise for the other 3 types),
// rather than every client's files interleaved flat at the ZIP root.
final Map<String, Object> photo = new LinkedHashMap<>();
photo.put("present", imageId != null);
entry.setPhoto(photo);
if (imageId != null && cifNumber != null) {
packageClientImage(zip, clientId, cifNumber + "-PI-PHOTO", photo);
packageClientImage(zip, clientId, PHOTO_FOLDER + cifNumber + "-PI-PHOTO", photo);
}
// Signature — packaged as "<CIF>-PI-SIGNATURE.<ext>"
// Signature — packaged as "SIGNATURE/<CIF>-PI-SIGNATURE.<ext>"
final Long signatureDocId = findSignatureDocId(clientId);
final Map<String, Object> signature = new LinkedHashMap<>();
signature.put("present", signatureDocId != null);
entry.setSignature(signature);
if (signatureDocId != null && cifNumber != null) {
packageFile(zip, "clients", clientId, signatureDocId,
cifNumber + "-PI-SIGNATURE", signature);
SIGNATURE_FOLDER + cifNumber + "-PI-SIGNATURE", signature);
}
// ID proof + Address proof: iterate m_client_identifier, classify by code value name.
// ID proof + Address proof: iterate m_client_identifier, classify by the m_code the
// identifier's document type actually belongs to (KYCIdProofType vs KYCAddressProofType) —
// the same source of truth KycProofTypesApiResource uses for the CKYC tab, so a document
// the client sees as "Address Proof: Available" is never silently dropped here.
// For each identifier, find the attached m_document (one-to-one, latest wins).
// NOTE: the `active` column in m_client_identifier stores the status code (200 = active)
// not a 1/0 boolean — so we filter on `status = 200` (ClientIdentifierStatus.ACTIVE).
final List<Map<String, Object>> identifiers = jdbcTemplate.queryForList(
"select ci.id as identifierId, ci.document_key as docKey, cv.code_value as codeValue "
"select ci.id as identifierId, ci.document_key as docKey, cv.code_value as codeValue, "
+ " c.code_name as codeName "
+ " from m_client_identifier ci "
+ " join m_code_value cv on cv.id = ci.document_type_id "
+ " join m_code c on c.id = cv.code_id "
+ " where ci.client_id = ? and (ci.status = 200 or ci.status is null)",
clientId);
@@ -221,8 +241,9 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
for (final Map<String, Object> idRow : identifiers) {
final Long identifierId = ((Number) idRow.get("identifierId")).longValue();
final String codeValue = (String) idRow.get("codeValue");
final String codeName = (String) idRow.get("codeName");
final String docKey = (String) idRow.get("docKey");
final boolean isAddress = ADDRESS_PROOF_ONLY.contains(codeValue == null ? "" : codeValue.toUpperCase(Locale.ROOT));
final boolean isAddress = ADDRESS_PROOF_CODE_NAME.equalsIgnoreCase(codeName);
final String role = isAddress ? "PA" : "PI";
final Long attachedDocId = findLatestDocForIdentifier(identifierId);
@@ -242,8 +263,9 @@ public class CkycBatchBuilderServiceImpl implements CkycBatchBuilderService {
bucket.put("key", docKey);
if (cifNumber != null) {
final String folder = isAddress ? ADDRESS_PROOF_FOLDER : ID_PROOF_FOLDER;
packageFile(zip, "client_identifiers", identifierId, attachedDocId,
cifNumber + "-" + role + "-" + shortCode, bucket);
folder + cifNumber + "-" + role + "-" + shortCode, bucket);
}
}
@@ -26,7 +26,6 @@ import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -36,6 +35,7 @@ import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecific
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Slf4j
@Service
@@ -219,6 +219,7 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService {
// ===== Customer List =====
@Override
@SuppressWarnings("StringSplitter")
public Map<String, Object> listCustomers(String name, String branch, String city, String gender, String state, String dateFrom,
String dateTo, Integer page, Integer pageSize, String sort) {
@@ -375,13 +376,13 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService {
switch (format != null ? format.toLowerCase() : "csv") {
case "txt":
contentType = "text/plain";
filename = "ckyc_export_" + LocalDate.now() + ".txt";
filename = "ckyc_export_" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + ".txt";
content = buildPipeDelimitedExport(rows);
break;
case "csv":
default:
contentType = "text/csv";
filename = "ckyc_export_" + LocalDate.now() + ".csv";
filename = "ckyc_export_" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + ".csv";
content = buildCsvExport(rows);
break;
}
@@ -431,7 +432,7 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService {
errors.add("Parse error: " + e.getMessage());
}
String fileId = "UPLOAD-" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "-" + System.currentTimeMillis();
String fileId = "UPLOAD-" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "-" + System.currentTimeMillis();
result.put("fileId", fileId);
result.put("totalRecords", totalRecords);
result.put("customers", customers);
@@ -453,9 +454,9 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService {
@Override
public Response generateCkycFile(Map<String, Object> request) {
// Placeholder — delegates to CkycBatchBuilderService for actual ZIP generation
String content = "10|BATCH|" + LocalDate.now() + "|0\n";
String content = "10|BATCH|" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + "|0\n";
return Response.ok(content.getBytes(StandardCharsets.UTF_8), "text/plain")
.header("Content-Disposition", "attachment; filename=\"ckyc_submission_" + LocalDate.now() + ".txt\"")
.header("Content-Disposition", "attachment; filename=\"ckyc_submission_" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) + ".txt\"")
.build();
}
@@ -572,7 +573,7 @@ public class CkycReportsReadServiceImpl implements CkycReportsReadService {
private String buildPipeDelimitedExport(List<Map<String, Object>> rows) {
StringBuilder sb = new StringBuilder();
sb.append("10|CKYC_EXPORT|").append(LocalDate.now()).append("|").append(rows.size()).append("\n");
sb.append("10|CKYC_EXPORT|").append(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).append("|").append(rows.size()).append("\n");
for (Map<String, Object> row : rows) {
sb.append("20|");
sb.append(val(row.get("cif"))).append("|");
@@ -47,6 +47,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Dummy / stub implementation of {@link CkycBatchSftpUploadService}.
@@ -149,10 +150,10 @@ public class DummyCkycBatchSftpUploadServiceImpl implements CkycBatchSftpUploadS
}
// 3) Build the ACK reference and timestamp
final LocalDateTime now = LocalDateTime.now();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String ackReference = "CERSAI-ACK-" + ACK_TS_FMT.format(now) + "-"
+ String.format("%04d", ACK_COUNTER.getAndIncrement());
final LocalDate today = LocalDate.now();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// 4) Parse manifest → generate a CKYC number per included client → persist on m_client_kyc
final List<Map<String, Object>> perClient = new ArrayList<>();
@@ -71,6 +71,15 @@ public class ClientKycCommand {
}
public void validateForInitiate() {
validateForInitiate(false);
}
/**
* @param skipIdentityDocumentCheck true for minors, who identify via the Birth Certificate /
* School ID document-type picker (a separate upload path)
* rather than PAN/Aadhaar/GSTIN.
*/
public void validateForInitiate(final boolean skipIdentityDocumentCheck) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(ClientKycApiConstants.RESOURCE_NAME);
@@ -101,13 +110,16 @@ public class ClientKycCommand {
"GSTIN must be a valid 15-character GST Identification Number");
}
// At least one ID document must be provided
final boolean hasPan = panNumber != null && !panNumber.isBlank();
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
if (!hasPan && !hasAadhaar && !hasGstin) {
v.reset().parameter(ClientKycApiConstants.panNumberParam).value(panNumber)
.notBlank();
// At least one ID document must be provided — except for minors, who prove identity via
// the Birth Certificate / School ID document-type picker instead.
if (!skipIdentityDocumentCheck) {
final boolean hasPan = panNumber != null && !panNumber.isBlank();
final boolean hasAadhaar = aadhaarNumberMasked != null && !aadhaarNumberMasked.isBlank();
final boolean hasGstin = gstinNumber != null && !gstinNumber.isBlank();
if (!hasPan && !hasAadhaar && !hasGstin) {
v.reset().parameter(ClientKycApiConstants.panNumberParam).value(panNumber)
.notBlank();
}
}
throwExceptionIfValidationWarningsExist(errors);
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.contactverification.api;
import com.google.common.base.Splitter;
import com.google.common.collect.Iterables;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
@@ -152,7 +154,7 @@ public class ClientContactVerificationApiResource {
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
return Iterables.get(Splitter.on(',').split(fwd), 0).trim();
}
return req.getRemoteAddr();
}
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.portfolio.client.contactverification.api;
import com.google.common.base.Splitter;
import com.google.common.collect.Iterables;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
@@ -147,7 +149,7 @@ public class ClientCreationContactVerificationApiResource {
}
final String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) {
return fwd.split(",")[0].trim();
return Iterables.get(Splitter.on(',').split(fwd), 0).trim();
}
return req.getRemoteAddr();
}
@@ -53,6 +53,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* See {@link ClientContactOtpService}. Deliberately does NOT touch {@code LoginOtpServiceImpl} /
@@ -209,7 +210,7 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
log.setExpiresAt(OffsetDateTime.now(ZoneOffset.UTC).plusSeconds(ttlSeconds));
final String html = emailTemplateService.renderOtp(null, otp, validityMinutes);
final String subject = "Your OTP for Contact Verification - " + appName + " (" + LocalDateTime.now().format(SUBJECT_TIMESTAMP)
final String subject = "Your OTP for Contact Verification - " + appName + " (" + LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(SUBJECT_TIMESTAMP)
+ ")";
final boolean sent = emailSenderService.sendHtml(emailAddress, subject, html);
@@ -32,8 +32,9 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
dateOfBirthParamName, genderIdParamName, salutationIdParamName, clientTypeIdParamName, clientClassificationIdParamName,
occupationCategoryIdParamName, occupationIdParamName, maritalStatusIdParamName,
educationLevelIdParamName, degreeQualificationIdParamName,
religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualTurnoverParamName,
agricultureIncomeParamName, nonAgricultureIncomeParamName,
religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualIncomeParamName, annualTurnoverParamName,
monthlyTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName,
isSeniorCitizenParamName, isPwdParamName, pwdCategoryIdParamName,
ageYearsParamName, ageMonthsParamName, ageDaysParamName,
nationalityIdParamName, isMinorParamName, guardianClientIdParamName, guardianRelationParamName,
dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName,
@@ -55,8 +56,9 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
dateOfBirthParamName, genderIdParamName, salutationIdParamName, clientTypeIdParamName, clientClassificationIdParamName,
occupationCategoryIdParamName, occupationIdParamName, maritalStatusIdParamName,
educationLevelIdParamName, degreeQualificationIdParamName,
religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualTurnoverParamName,
agricultureIncomeParamName, nonAgricultureIncomeParamName,
religionIdParamName, socialCategoryIdParamName, monthlyIncomeParamName, annualIncomeParamName, annualTurnoverParamName,
monthlyTurnoverParamName, agricultureIncomeParamName, nonAgricultureIncomeParamName,
isSeniorCitizenParamName, isPwdParamName, pwdCategoryIdParamName,
ageYearsParamName, ageMonthsParamName, ageDaysParamName,
nationalityIdParamName, isMinorParamName, guardianClientIdParamName, guardianRelationParamName,
dipVerifiedParamName, dipLookupLogIdParamName, dipTimestampParamName,
@@ -0,0 +1,44 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class ClientPendingEditData {
private final Long id;
private final Long clientId;
private final String clientDisplayName;
private final String payloadJson;
private final String originalJson;
private final String status;
private final Long makerId;
private final String makerName;
private final OffsetDateTime submittedDate;
private final Long checkerId;
private final String checkerName;
private final OffsetDateTime checkedDate;
private final String rejectReason;
}
@@ -0,0 +1,133 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import java.io.Serializable;
import java.math.BigDecimal;
public final class KycRiskRuleData implements Serializable {
private static final long serialVersionUID = 1L;
private final Long id;
private final String ruleName;
private final Integer legalFormId;
private final Long occupationCategoryCvId;
private final String occupationCategoryName;
private final Long occupationCvId;
private final String occupationName;
private final BigDecimal minMonthlyIncome;
private final BigDecimal maxMonthlyIncome;
private final BigDecimal minAnnualIncome;
private final BigDecimal maxAnnualIncome;
private final BigDecimal minAnnualTurnover;
private final BigDecimal maxAnnualTurnover;
private final Integer riskCategory;
private final String reason;
private final Integer orderPosition;
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName,
final Long occupationCvId, final String occupationName,
final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome,
final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
final Integer riskCategory, final String reason, final Integer orderPosition) {
this.id = id;
this.ruleName = ruleName;
this.legalFormId = legalFormId;
this.occupationCategoryCvId = occupationCategoryCvId;
this.occupationCategoryName = occupationCategoryName;
this.occupationCvId = occupationCvId;
this.occupationName = occupationName;
this.minMonthlyIncome = minMonthlyIncome;
this.maxMonthlyIncome = maxMonthlyIncome;
this.minAnnualIncome = minAnnualIncome;
this.maxAnnualIncome = maxAnnualIncome;
this.minAnnualTurnover = minAnnualTurnover;
this.maxAnnualTurnover = maxAnnualTurnover;
this.riskCategory = riskCategory;
this.reason = reason;
this.orderPosition = orderPosition;
}
public Long getId() {
return id;
}
public String getRuleName() {
return ruleName;
}
public Integer getLegalFormId() {
return legalFormId;
}
public Long getOccupationCategoryCvId() {
return occupationCategoryCvId;
}
public String getOccupationCategoryName() {
return occupationCategoryName;
}
public Long getOccupationCvId() {
return occupationCvId;
}
public String getOccupationName() {
return occupationName;
}
public BigDecimal getMinMonthlyIncome() {
return minMonthlyIncome;
}
public BigDecimal getMaxMonthlyIncome() {
return maxMonthlyIncome;
}
public BigDecimal getMinAnnualIncome() {
return minAnnualIncome;
}
public BigDecimal getMaxAnnualIncome() {
return maxAnnualIncome;
}
public BigDecimal getMinAnnualTurnover() {
return minAnnualTurnover;
}
public BigDecimal getMaxAnnualTurnover() {
return maxAnnualTurnover;
}
public Integer getRiskCategory() {
return riskCategory;
}
public String getReason() {
return reason;
}
public Integer getOrderPosition() {
return orderPosition;
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.client.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
@@ -52,4 +53,11 @@ public interface ClientFamilyMembersRepository
@Param("relationshipId") Long relationshipId,
@Param("excludeFamilyMemberId") Long excludeFamilyMemberId);
/**
* All non-deleted family members for a client, with their relationship CodeValue. Backs the
* "Mother required, plus Father or Caretaker" activation gate.
*/
@Query("SELECT fm FROM ClientFamilyMembers fm WHERE fm.client.id = :clientId AND fm.isDeleted = false")
List<ClientFamilyMembers> findAllActiveByClient(@Param("clientId") Long clientId);
}
@@ -0,0 +1,132 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Bespoke maker-checker for Client edits — modelled on {@link ClientPendingEditStatus} /
* {@code m_share_account_pending_edit} elsewhere in this codebase rather than Fineract's native
* command-approval engine, because that engine's hold is bypassed by any role carrying
* ALL_FUNCTIONS (Super user, Clerk in this tenant), making it a no-op for this CBS's actual admin
* accounts.
*
* <p>Holds the maker's raw PUT /clients/{id}/edit payload as-is (rather than one typed column per
* editable Client field, which — given how many fields Client actually has across general,
* financial-classification and KYC-adjacent data — would mean a huge, fragile shadow-column table
* that has to be kept in lockstep with the entity forever). Approval reconstructs a
* {@code JsonCommand} from the stored JSON and replays it through the existing
* {@code ClientWritePlatformService.updateClient(...)}, so all of Client's existing validation and
* change-detection logic runs exactly as it would for a direct edit — just deferred until a
* different user approves it. The maker can never approve/reject their own submission — enforced
* in the write service, not just by role, with no bypass for CHECKER_SUPER_USER.
*/
@Entity
@Table(name = "m_client_pending_edit")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "payload_json", nullable = false)
private String payloadJson;
/** Point-in-time "before" snapshot the maker's client had when the edit was submitted — see
* client-edit-diff.util.ts's buildOriginalValuesSnapshot(). Null for edits submitted before
* this column existed. Lets the checker's compare view show a true historical diff regardless
* of whether the live client has since changed (e.g. once approved, current == proposed). */
@Column(name = "original_json")
private String originalJson;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "maker_id", nullable = false)
private Long makerId;
@Column(name = "submitted_date", nullable = false)
private OffsetDateTime submittedDate;
@Column(name = "checker_id")
private Long checkerId;
@Column(name = "checked_date")
private OffsetDateTime checkedDate;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
@Column(name = "pending_lock_key", length = 60, unique = true)
private String pendingLockKey;
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
final AppUser maker) {
final ClientPendingEdit edit = new ClientPendingEdit();
edit.client = client;
edit.payloadJson = payloadJson;
edit.originalJson = originalJson;
edit.status = ClientPendingEditStatus.PENDING.name();
edit.makerId = maker.getId();
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
edit.pendingLockKey = lockKeyFor(client.getId());
return edit;
}
public static String lockKeyFor(final Long clientId) {
return "CLIENT_EDIT:" + clientId;
}
public boolean isPending() {
return ClientPendingEditStatus.PENDING.name().equals(this.status);
}
/** The maker can never be their own checker — a distinct human must approve/reject an edit. */
public boolean isMadeBy(final Long userId) {
return this.makerId != null && this.makerId.equals(userId);
}
public void approve(final AppUser checker) {
this.status = ClientPendingEditStatus.APPROVED.name();
this.checkerId = checker.getId();
this.checkedDate = DateUtils.getOffsetDateTimeOfTenant();
this.pendingLockKey = null;
}
public void reject(final AppUser checker, final String reason) {
this.status = ClientPendingEditStatus.REJECTED.name();
this.checkerId = checker.getId();
this.checkedDate = DateUtils.getOffsetDateTimeOfTenant();
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClientPendingEditRepository extends JpaRepository<ClientPendingEdit, Long> {
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
}
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.exception.ClientNoPendingEditException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ClientPendingEditRepositoryWrapper {
private final ClientPendingEditRepository repository;
public ClientPendingEdit findPendingByClientIdWithNotFoundDetection(final Long clientId) {
return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name())
.orElseThrow(() -> new ClientNoPendingEditException(clientId));
}
public Optional<ClientPendingEdit> findPendingByClientId(final Long clientId) {
return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name());
}
public List<ClientPendingEdit> findAllPending() {
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
}
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
return this.repository.saveAndFlush(edit);
}
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
/** Lifecycle of a {@link ClientPendingEdit}. Terminal once APPROVED or REJECTED. */
public enum ClientPendingEditStatus {
PENDING, //
APPROVED, //
REJECTED; //
}
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when the user attempting to activate a client is the same user who created it — a
* four-eyes (maker-checker) requirement with no bypass, including for users holding
* CHECKER_SUPER_USER, since a bypass would recreate the exact "ALL_FUNCTIONS/super user ignores
* the hold" loophole this check exists to close.
*/
public class ClientActivationSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public ClientActivationSelfApprovalNotAllowedException() {
super("error.msg.client.activation.self.approval.not.allowed",
"You cannot activate a client you created yourself — a different user must activate it.");
}
}
@@ -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.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ClientEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public ClientEditPendingApprovalException(final Long clientId) {
super("error.msg.client.edit.pending.approval", "Client with identifier " + clientId + " already has an edit pending approval",
clientId);
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when the user attempting to approve/reject a Client's pending edit is the same user who
* submitted it — no bypass, including for users holding CHECKER_SUPER_USER, since a bypass would
* recreate the exact "role bypasses the hold" loophole this check exists to close.
*/
public class ClientEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public ClientEditSelfApprovalNotAllowedException(final Long clientId) {
super("error.msg.client.edit.self.approval.not.allowed",
"You cannot approve or reject an edit you submitted yourself for client " + clientId
+ " — a different user must review it.",
clientId);
}
}
@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown on client activation when neither an active "Father" nor "Caretaker" family member has
* been recorded. Kept separate from {@link ClientFamilyMemberMotherRequiredException} so the two
* failure reasons surface as distinct errors instead of one merged message.
*/
public class ClientFamilyMemberFatherOrCaretakerRequiredException extends AbstractPlatformDomainRuleException {
public ClientFamilyMemberFatherOrCaretakerRequiredException() {
super("error.msg.client.family.member.father.or.caretaker.required",
"Either Father or a Caretaker is required as a family member before this client can be activated.");
}
}
@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown on client activation when no active "Mother" family member has been recorded. Kept
* separate from {@link ClientFamilyMemberFatherOrCaretakerRequiredException} so the two failure
* reasons surface as distinct errors instead of one merged message.
*/
public class ClientFamilyMemberMotherRequiredException extends AbstractPlatformDomainRuleException {
public ClientFamilyMemberMotherRequiredException() {
super("error.msg.client.family.member.mother.required",
"Mother is required as a family member before this client can be activated.");
}
}
@@ -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.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ClientNoPendingEditException extends AbstractPlatformDomainRuleException {
public ClientNoPendingEditException(final Long clientId) {
super("error.msg.client.no.pending.edit", "Client with identifier " + clientId + " has no edit pending approval", clientId);
}
}
@@ -34,6 +34,7 @@ import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Mapper(config = MapstructMapperConfig.class)
public interface ClientMapper {
@@ -65,12 +66,14 @@ public interface ClientMapper {
@Mapping(target = "degreeQualification", source = "source", qualifiedByName = "degreeQualificationCode")
@Mapping(target = "religion", source = "source", qualifiedByName = "religionCode")
@Mapping(target = "socialCategory", source = "source", qualifiedByName = "socialCategoryCode")
@Mapping(target = "pwdCategory", source = "source", qualifiedByName = "pwdCategoryCode")
@Mapping(target = "occupationCategoryOptions", ignore = true)
@Mapping(target = "occupationOptions", ignore = true)
@Mapping(target = "educationLevelOptions", ignore = true)
@Mapping(target = "degreeQualificationOptions", ignore = true)
@Mapping(target = "religionOptions", ignore = true)
@Mapping(target = "socialCategoryOptions", ignore = true)
@Mapping(target = "pwdCategoryOptions", ignore = true)
@Mapping(target = "legalForm", source = "source", qualifiedByName = "clientLegalFormEnum")
@Mapping(target = "isStaff", source = "source", qualifiedByName = "clientIsStaff")
@Mapping(target = "ageYears", ignore = true)
@@ -196,6 +199,15 @@ public interface ClientMapper {
return CodeValueData.instance(code.getId(), code.getLabel());
}
@Named("pwdCategoryCode")
default CodeValueData pwdCategoryCode(Client client) {
final CodeValue code = client.getPwdCategory();
if (code == null) {
return null;
}
return CodeValueData.instance(code.getId(), code.getLabel());
}
@Named("clientSubStatusCode")
default CodeValueData clientSubStatusCode(Client client) {
final CodeValue code = client.getSubStatus();
@@ -271,7 +283,7 @@ public interface ClientMapper {
default void computeAge(Client source, @MappingTarget ClientData target) {
final LocalDate dob = source.getDateOfBirth();
if (dob != null) {
final Period age = Period.between(dob, LocalDate.now());
final Period age = Period.between(dob, LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
target.setAgeYears(age.getYears());
target.setAgeMonths(age.getMonths());
target.setAgeDays(age.getDays());
@@ -25,6 +25,7 @@ import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.apache.fineract.portfolio.client.domain.LegalForm;
import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>:
@@ -90,6 +91,6 @@ public class CifNumberGenerator {
private int resolveYear(final Client client) {
final LocalDate submittedOn = client.getSubmittedOnDate();
return (submittedOn != null ? submittedOn : LocalDate.now()).getYear();
return (submittedOn != null ? submittedOn : LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYear();
}
}
@@ -30,6 +30,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* See {@link ClientChangeNotificationService}.
@@ -88,7 +89,7 @@ public class ClientChangeNotificationServiceImpl implements ClientChangeNotifica
final String alertTitle = fieldLabel + " Updated";
final String alertMessage = "Your " + fieldLabel.toLowerCase() + " on file was changed from "
+ describeForDisplay(fieldLabel, oldValue) + " to " + describeForDisplay(fieldLabel, newValue) + " on "
+ LocalDateTime.now().format(TIMESTAMP) + ". If you did not authorize this change, please contact us immediately.";
+ LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()).format(TIMESTAMP) + ". If you did not authorize this change, please contact us immediately.";
final String html = emailTemplateService.renderAlert(recipientName, alertTitle, alertMessage);
final String subject = alertTitle + " - " + appName;
@@ -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.client.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
/** Maker-checker for Client edits — see {@link org.apache.fineract.portfolio.client.domain.ClientPendingEdit}. */
public interface ClientEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
CommandProcessingResult approveEdit(Long clientId);
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
}
@@ -0,0 +1,136 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Service
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
private final PlatformSecurityContext context;
private final ClientRepositoryWrapper clientRepository;
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
private final ClientWritePlatformService clientWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long clientId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
// Four-eyes lock: refuse a second maker while one edit is already awaiting approval for
// this client, rather than silently overwriting or queueing it — matches
// m_share_account_pending_edit / m_savings_product_temp elsewhere in this codebase.
if (this.pendingEditRepository.findPendingByClientId(clientId).isPresent()) {
throw new ClientEditPendingApprovalException(clientId);
}
// The frontend rides its point-in-time "before" snapshot along under _originalValues so
// the checker's compare view can show a true historical diff later, even after the edit
// has been applied (by then the live client equals the proposed values, not the original
// ones — see client-edit-diff.util.ts). Split it out here: the rest replays through
// updateClient() at approval time and must not carry an unrecognised parameter.
final JsonElement parsedPayload = this.fromApiJsonHelper.parse(command.json());
String originalJson = null;
String cleanPayloadJson = command.json();
if (parsedPayload != null && parsedPayload.isJsonObject()) {
final JsonObject payloadObject = parsedPayload.getAsJsonObject();
final JsonElement originalValues = payloadObject.remove(ORIGINAL_VALUES_PAYLOAD_KEY);
if (originalValues != null) {
originalJson = originalValues.toString();
}
cleanPayloadJson = payloadObject.toString();
}
final ClientPendingEdit pendingEdit = ClientPendingEdit.submit(client, cleanPayloadJson, originalJson, maker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withOfficeId(client.officeId()) //
.withClientId(clientId) //
.withEntityId(pendingEdit.getId()) //
.build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long clientId) {
final AppUser checker = this.context.authenticatedUser();
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
// No bypass — including CHECKER_SUPER_USER — a bypass here would recreate the exact
// "role bypasses the hold" loophole this whole feature exists to close.
if (pendingEdit.isMadeBy(checker.getId())) {
throw new ClientEditSelfApprovalNotAllowedException(clientId);
}
// Replay the staged JSON through the SAME write-service method a direct PUT would have
// used, so every bit of Client's existing validation/change-detection logic runs exactly
// as it would for an immediate edit — just deferred until a different user approves it.
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
pendingEdit.approve(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
return result;
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long clientId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
if (pendingEdit.isMadeBy(checker.getId())) {
throw new ClientEditSelfApprovalNotAllowedException(clientId);
}
pendingEdit.reject(checker, rejectReason);
this.pendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder() //
.withClientId(clientId) //
.withEntityId(pendingEdit.getId()) //
.build();
}
}
@@ -33,4 +33,12 @@ public interface ClientFamilyMembersWritePlatformService {
CommandProcessingResult deleteFamilyMember(Long familyMemberId, JsonCommand command);
/**
* Enforces that an individual client has "Mother" plus either "Father" or "Caretaker" recorded
* as active family members. No-op for non-individual (entity) clients. Throws
* {@code ClientFamilyMemberMotherRequiredException} or
* {@code ClientFamilyMemberFatherOrCaretakerRequiredException} on failure.
*/
void validateFamilyRequirementsForActivation(Client client);
}
@@ -37,10 +37,13 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import java.util.List;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientFamilyMembers;
import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -131,6 +134,47 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
}
}
@Override
public void validateFamilyRequirementsForActivation(final Client client) {
if (client == null || client.getId() == null || client.getLegalForm() == null) {
return;
}
// Family (Mother/Father/Caretaker) is an individual-client concept — see
// assertCanHaveFamilyMembers above; non-individual entities are exempt from this gate.
if (org.apache.fineract.portfolio.client.domain.LegalForm.fromInt(client.getLegalForm()).isEntity()) {
return;
}
final List<ClientFamilyMembers> members = clientFamilyRepository.findAllActiveByClient(client.getId());
boolean hasMother = false;
boolean hasFather = false;
boolean hasCaretaker = false;
for (final ClientFamilyMembers member : members) {
final CodeValue relationship = member.getRelationship();
if (relationship == null || relationship.getLabel() == null) {
continue;
}
final String label = relationship.getLabel().trim().toLowerCase(Locale.ROOT);
switch (label) {
case "mother" -> hasMother = true;
case "father" -> hasFather = true;
case "caretaker" -> hasCaretaker = true;
default -> {
// other relationships don't affect this rule
}
}
}
// Reported as two independent failures rather than one merged message, so the caller can
// tell which specific requirement is unmet.
if (!hasMother) {
throw new ClientFamilyMemberMotherRequiredException();
}
if (!hasFather && !hasCaretaker) {
throw new ClientFamilyMemberFatherOrCaretakerRequiredException();
}
}
@Override
public CommandProcessingResult addFamilyMember(final long clientId, final JsonCommand command) {
@@ -84,9 +84,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
this.context.authenticatedUser();
LOG.info("initiateKyc: clientId={}, requestJson={}", clientId, command.json());
final Client clientForValidation = clientRepository.findOneWithNotFoundDetection(clientId);
final ClientKycCommand kycCommand = deserializer.commandFromApiJson(
command.json(), ClientKycApiConstants.INITIATE_REQUEST_PARAMS);
kycCommand.validateForInitiate();
// Minors identify via the Birth Certificate / School ID document-type picker (a separate
// upload path), not PAN/Aadhaar/GSTIN — so the "at least one ID" check doesn't apply to them.
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
final Integer kycTypeOnInitiate = command.integerValueOfParameterNamed("kycType");
@@ -104,7 +108,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
}
try {
final Client client = clientRepository.findOneWithNotFoundDetection(clientId);
final Client client = clientForValidation;
final ClientKyc kyc = ClientKyc.initiateKyc(client, command);
kycRepositoryWrapper.saveAndFlush(kyc);
@@ -957,7 +961,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
public void initiateKycInline(final Client client, final String kycJson) {
final ClientKycCommand kycCommand = deserializer.commandFromApiJson(
kycJson, ClientKycApiConstants.INITIATE_REQUEST_PARAMS);
kycCommand.validateForInitiate();
kycCommand.validateForInitiate(Boolean.TRUE.equals(client.getIsMinor()));
if (kycRepositoryWrapper.existsByClientId(client.getId())) {
LOG.info("KYC record already exists for client {}. Skipping inline initiation.", client.getId());
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
public interface ClientPendingEditReadPlatformService {
ClientPendingEditData retrievePendingEdit(Long clientId);
/**
* @param status defaults to PENDING when blank
* @param clientName case-insensitive substring match against the client's display name
* @param fromDate ISO (yyyy-MM-dd), inclusive, filters on submitted_date
* @param toDate ISO (yyyy-MM-dd), inclusive, filters on submitted_date
*/
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
Integer limit);
}
@@ -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.client.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
import org.apache.fineract.portfolio.client.exception.ClientNoPendingEditException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEditReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final String SELECT_SQL = """
SELECT pe.id AS id, pe.client_id AS clientId, c.display_name AS clientDisplayName,
pe.payload_json AS payloadJson, pe.original_json AS originalJson, pe.status AS status,
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
pe.submitted_date AS submittedDate,
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason
FROM m_client_pending_edit pe
JOIN m_client c ON c.id = pe.client_id
JOIN m_appuser maker ON maker.id = pe.maker_id
LEFT JOIN m_appuser checker ON checker.id = pe.checker_id
""";
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
return ClientPendingEditData.builder() //
.id(rs.getLong("id")) //
.clientId(rs.getLong("clientId")) //
.clientDisplayName(rs.getString("clientDisplayName")) //
.payloadJson(rs.getString("payloadJson")) //
.originalJson(rs.getString("originalJson")) //
.status(rs.getString("status")) //
.makerId(rs.getLong("makerId")) //
.makerName(rs.getString("makerName")) //
.submittedDate(submittedDate) //
.checkerId(JdbcSupport.getLong(rs, "checkerId")) //
.checkerName(rs.getString("checkerName")) //
.checkedDate(checkedDate) //
.rejectReason(rs.getString("rejectReason")) //
.build();
};
@Override
public ClientPendingEditData retrievePendingEdit(final Long clientId) {
try {
return this.jdbcTemplate.queryForObject(SELECT_SQL + " WHERE pe.client_id = ? AND pe.status = 'PENDING'", MAPPER, clientId);
} catch (final EmptyResultDataAccessException e) {
throw new ClientNoPendingEditException(clientId);
}
}
@Override
public Page<ClientPendingEditData> retrieveAllPendingEdits(final String status, final String clientName, final String fromDate,
final String toDate, final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" WHERE 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
where.append(" AND pe.status = ?");
args.add(status.trim().toUpperCase());
} else {
where.append(" AND pe.status = 'PENDING'");
}
if (StringUtils.isNotBlank(clientName)) {
where.append(" AND c.display_name ILIKE ?");
args.add("%" + clientName.trim() + "%");
}
if (StringUtils.isNotBlank(fromDate)) {
where.append(" AND pe.submitted_date::date >= ?::date");
args.add(fromDate);
}
if (StringUtils.isNotBlank(toDate)) {
where.append(" AND pe.submitted_date::date <= ?::date");
args.add(toDate);
}
final String countSql = "SELECT COUNT(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id" + where;
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final int pageOffset = offset == null ? 0 : offset;
final int pageLimit = limit == null ? 10 : limit;
final String pageSql = SELECT_SQL + where + " ORDER BY pe.submitted_date DESC LIMIT ? OFFSET ?";
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(pageLimit);
pageArgs.add(pageOffset);
final List<ClientPendingEditData> items = this.jdbcTemplate.query(pageSql, MAPPER, pageArgs.toArray());
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
}
}
@@ -63,6 +63,7 @@ import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
@RequiredArgsConstructor
@@ -177,6 +178,16 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
extraCriteria += " and c.status_enum = " + clientStatus.getValue().toString() + " ";
}
if (searchParameters.hasFromDate()) {
paramList.add(searchParameters.getFromDate().toString());
extraCriteria += " and c.submittedon_date >= ?::date ";
}
if (searchParameters.hasToDate()) {
paramList.add(searchParameters.getToDate().toString());
extraCriteria += " and c.submittedon_date <= ?::date ";
}
if (firstname != null) {
paramList.add(firstname);
extraCriteria += " and c.firstname like ? ";
@@ -321,7 +332,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
sqlBuilder.append("c.occupation_cv_id as occupationId, ");
sqlBuilder.append("cvOccupation.code_value as occupationValue, ");
sqlBuilder.append("c.monthly_income as monthlyIncome, ");
sqlBuilder.append("c.annual_income as annualIncome, ");
sqlBuilder.append("c.annual_turnover as annualTurnover, ");
sqlBuilder.append("c.monthly_turnover as monthlyTurnover, ");
sqlBuilder.append("c.is_senior_citizen as isSeniorCitizen, ");
sqlBuilder.append("c.is_pwd as isPwd, ");
sqlBuilder.append("c.pwd_category_cv_id as pwdCategoryId, ");
sqlBuilder.append("cvPwdCategory.code_value as pwdCategoryValue, ");
sqlBuilder.append("c.nationality as nationality, ");
sqlBuilder.append("c.is_minor as isMinor, ");
sqlBuilder.append("c.guardian_client_id as guardianClientId, ");
@@ -383,6 +400,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
sqlBuilder.append("left join m_code_value cvclassification on cvclassification.id = c.client_classification_cv_id ");
sqlBuilder.append("left join m_code_value cvSubStatus on cvSubStatus.id = c.sub_status ");
sqlBuilder.append("left join m_code_value cvOccupationCategory on cvOccupationCategory.id = c.occupation_category_cv_id ");
sqlBuilder.append("left join m_code_value cvPwdCategory on cvPwdCategory.id = c.pwd_category_cv_id ");
sqlBuilder.append("left join m_code_value cvOccupation on cvOccupation.id = c.occupation_cv_id ");
sqlBuilder.append("left join m_client gc on gc.id = c.guardian_client_id ");
sqlBuilder.append("left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id ");
@@ -482,13 +500,20 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
final CodeValueData occupation = CodeValueData.instance(occupationId, occupationValue);
final BigDecimal monthlyIncome1 = rs.getBigDecimal("monthlyIncome");
final BigDecimal annualIncome1 = rs.getBigDecimal("annualIncome");
final BigDecimal annualTurnover1 = rs.getBigDecimal("annualTurnover");
final BigDecimal monthlyTurnover1 = rs.getBigDecimal("monthlyTurnover");
final Boolean isSeniorCitizen1 = rs.getBoolean("isSeniorCitizen");
final Boolean isPwd1 = rs.getBoolean("isPwd");
final Long pwdCategoryId1 = JdbcSupport.getLong(rs, "pwdCategoryId");
final String pwdCategoryValue1 = rs.getString("pwdCategoryValue");
final CodeValueData pwdCategory1 = CodeValueData.instance(pwdCategoryId1, pwdCategoryValue1);
Integer ageYears1 = null;
Integer ageMonths1 = null;
Integer ageDays1 = null;
if (dateOfBirth != null) {
final Period age = Period.between(dateOfBirth, LocalDate.now());
final Period age = Period.between(dateOfBirth, LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
ageYears1 = age.getYears();
ageMonths1 = age.getMonths();
ageDays1 = age.getDays();
@@ -520,7 +545,12 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
savingsProductName, savingsAccountId, clienttype, classification, occupationCategory, occupation, legalForm,
clientNonPerson, isStaff);
clientData.setMonthlyIncome(monthlyIncome1);
clientData.setAnnualIncome(annualIncome1);
clientData.setAnnualTurnover(annualTurnover1);
clientData.setMonthlyTurnover(monthlyTurnover1);
clientData.setIsSeniorCitizen(isSeniorCitizen1);
clientData.setIsPwd(isPwd1);
clientData.setPwdCategory(pwdCategory1);
clientData.setAgeYears(ageYears1);
clientData.setAgeMonths(ageMonths1);
clientData.setAgeDays(ageDays1);
@@ -714,7 +744,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
builder.append("c.occupation_cv_id as occupationId, ");
builder.append("cvOccupation.code_value as occupationValue, ");
builder.append("c.monthly_income as monthlyIncome, ");
builder.append("c.annual_income as annualIncome, ");
builder.append("c.annual_turnover as annualTurnover, ");
builder.append("c.monthly_turnover as monthlyTurnover, ");
builder.append("c.is_senior_citizen as isSeniorCitizen, ");
builder.append("c.is_pwd as isPwd, ");
builder.append("c.pwd_category_cv_id as pwdCategoryId, ");
builder.append("cvPwdCategory.code_value as pwdCategoryValue, ");
builder.append("c.nationality as nationality, ");
builder.append("c.is_minor as isMinor, ");
builder.append("c.guardian_client_id as guardianClientId, ");
@@ -775,6 +811,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
builder.append("left join m_code_value cvclassification on cvclassification.id = c.client_classification_cv_id ");
builder.append("left join m_code_value cvSubStatus on cvSubStatus.id = c.sub_status ");
builder.append("left join m_code_value cvOccupationCategory on cvOccupationCategory.id = c.occupation_category_cv_id ");
builder.append("left join m_code_value cvPwdCategory on cvPwdCategory.id = c.pwd_category_cv_id ");
builder.append("left join m_code_value cvOccupation on cvOccupation.id = c.occupation_cv_id ");
builder.append("left join m_client gc on gc.id = c.guardian_client_id ");
builder.append("left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id ");
@@ -873,13 +910,20 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
final CodeValueData occupation = CodeValueData.instance(occupationId, occupationValue);
final BigDecimal monthlyIncome = rs.getBigDecimal("monthlyIncome");
final BigDecimal annualIncome = rs.getBigDecimal("annualIncome");
final BigDecimal annualTurnover = rs.getBigDecimal("annualTurnover");
final BigDecimal monthlyTurnover = rs.getBigDecimal("monthlyTurnover");
final Boolean isSeniorCitizen = rs.getBoolean("isSeniorCitizen");
final Boolean isPwd = rs.getBoolean("isPwd");
final Long pwdCategoryId = JdbcSupport.getLong(rs, "pwdCategoryId");
final String pwdCategoryValue = rs.getString("pwdCategoryValue");
final CodeValueData pwdCategory = CodeValueData.instance(pwdCategoryId, pwdCategoryValue);
Integer ageYears = null;
Integer ageMonths = null;
Integer ageDays = null;
if (dateOfBirth != null) {
final Period age = Period.between(dateOfBirth, LocalDate.now());
final Period age = Period.between(dateOfBirth, LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
ageYears = age.getYears();
ageMonths = age.getMonths();
ageDays = age.getDays();
@@ -911,7 +955,12 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
savingsProductName, savingsAccountId, clienttype, classification, occupationCategory, occupation, legalForm,
clientNonPerson, isStaff);
clientData.setMonthlyIncome(monthlyIncome);
clientData.setAnnualIncome(annualIncome);
clientData.setAnnualTurnover(annualTurnover);
clientData.setMonthlyTurnover(monthlyTurnover);
clientData.setIsSeniorCitizen(isSeniorCitizen);
clientData.setIsPwd(isPwd);
clientData.setPwdCategory(pwdCategory);
clientData.setAgeYears(ageYears);
clientData.setAgeMonths(ageMonths);
clientData.setAgeDays(ageDays);
@@ -126,6 +126,9 @@ public class ClientTemplateReadPlatformServiceImpl implements ClientTemplateRead
final List<CodeValueData> socialCategoryOptions = new ArrayList<>(
this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.SOCIAL_CATEGORY));
final List<CodeValueData> pwdCategoryOptions = new ArrayList<>(
this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.PWD_CATEGORY));
final List<CodeValueData> clientNonPersonConstitutionOptions = new ArrayList<>(
this.codeValueReadPlatformService.retrieveCodeValuesByCode(ClientApiConstants.CLIENT_NON_PERSON_CONSTITUTION));
@@ -146,6 +149,7 @@ public class ClientTemplateReadPlatformServiceImpl implements ClientTemplateRead
templateData.setDegreeQualificationOptions(degreeQualificationOptions);
templateData.setReligionOptions(religionOptions);
templateData.setSocialCategoryOptions(socialCategoryOptions);
templateData.setPwdCategoryOptions(pwdCategoryOptions);
return templateData;
}
@@ -74,6 +74,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientStatus;
import org.apache.fineract.portfolio.client.domain.LegalForm;
import org.apache.fineract.portfolio.client.exception.ClientActivationSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.client.exception.ClientActiveForUpdateException;
import org.apache.fineract.portfolio.client.exception.ClientHasNoStaffException;
import org.apache.fineract.portfolio.client.exception.ClientMustBePendingToBeDeletedException;
@@ -395,6 +396,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
.findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.SOCIAL_CATEGORY, socialCategoryId);
}
CodeValue pwdCategory = null;
final Long pwdCategoryId = command.longValueOfParameterNamed(ClientApiConstants.pwdCategoryIdParamName);
if (pwdCategoryId != null) {
pwdCategory = this.codeValueRepository
.findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.PWD_CATEGORY, pwdCategoryId);
}
final Long savingsProductId = command.longValueOfParameterNamed(ClientApiConstants.savingsProductIdParamName);
if (savingsProductId != null) {
this.savingsProductRepository.findById(savingsProductId)
@@ -481,11 +489,31 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
newClient.setMonthlyIncome(monthlyIncome);
}
final java.math.BigDecimal annualIncome = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualIncomeParamName);
if (annualIncome != null) {
newClient.setAnnualIncome(annualIncome);
}
final java.math.BigDecimal annualTurnover = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualTurnoverParamName);
if (annualTurnover != null) {
newClient.setAnnualTurnover(annualTurnover);
}
final java.math.BigDecimal monthlyTurnover = command.bigDecimalValueOfParameterNamed(ClientApiConstants.monthlyTurnoverParamName);
if (monthlyTurnover != null) {
newClient.setMonthlyTurnover(monthlyTurnover);
}
final Boolean isSeniorCitizen = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isSeniorCitizenParamName);
newClient.setIsSeniorCitizen(Boolean.TRUE.equals(isSeniorCitizen));
final Boolean isPwd = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isPwdParamName);
newClient.setIsPwd(Boolean.TRUE.equals(isPwd));
if (pwdCategory != null) {
newClient.updatePwdCategory(pwdCategory);
}
final java.math.BigDecimal agricultureIncome = command.bigDecimalValueOfParameterNamed(ClientApiConstants.agricultureIncomeParamName);
if (agricultureIncome != null) {
newClient.setAgricultureIncome(agricultureIncome);
@@ -964,12 +992,47 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
clientForUpdate.setMonthlyIncome(newValue);
}
if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.annualIncomeParamName, clientForUpdate.getAnnualIncome())) {
final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualIncomeParamName);
changes.put(ClientApiConstants.annualIncomeParamName, newValue);
clientForUpdate.setAnnualIncome(newValue);
}
if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.annualTurnoverParamName, clientForUpdate.getAnnualTurnover())) {
final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.annualTurnoverParamName);
changes.put(ClientApiConstants.annualTurnoverParamName, newValue);
clientForUpdate.setAnnualTurnover(newValue);
}
if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.monthlyTurnoverParamName, clientForUpdate.getMonthlyTurnover())) {
final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.monthlyTurnoverParamName);
changes.put(ClientApiConstants.monthlyTurnoverParamName, newValue);
clientForUpdate.setMonthlyTurnover(newValue);
}
if (command.isChangeInBooleanParameterNamed(ClientApiConstants.isSeniorCitizenParamName, clientForUpdate.getIsSeniorCitizen())) {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isSeniorCitizenParamName);
changes.put(ClientApiConstants.isSeniorCitizenParamName, newValue);
clientForUpdate.setIsSeniorCitizen(Boolean.TRUE.equals(newValue));
}
if (command.isChangeInBooleanParameterNamed(ClientApiConstants.isPwdParamName, clientForUpdate.getIsPwd())) {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(ClientApiConstants.isPwdParamName);
changes.put(ClientApiConstants.isPwdParamName, newValue);
clientForUpdate.setIsPwd(Boolean.TRUE.equals(newValue));
}
if (command.isChangeInLongParameterNamed(ClientApiConstants.pwdCategoryIdParamName, clientForUpdate.pwdCategoryId())) {
final Long newValue = command.longValueOfParameterNamed(ClientApiConstants.pwdCategoryIdParamName);
changes.put(ClientApiConstants.pwdCategoryIdParamName, newValue);
CodeValue newCodeVal = null;
if (newValue != null) {
newCodeVal = this.codeValueRepository
.findOneByCodeNameAndIdWithNotFoundDetection(ClientApiConstants.PWD_CATEGORY, newValue);
}
clientForUpdate.updatePwdCategory(newCodeVal);
}
if (command.isChangeInBigDecimalParameterNamed(ClientApiConstants.agricultureIncomeParamName, clientForUpdate.getAgricultureIncome())) {
final java.math.BigDecimal newValue = command.bigDecimalValueOfParameterNamed(ClientApiConstants.agricultureIncomeParamName);
changes.put(ClientApiConstants.agricultureIncomeParamName, newValue);
@@ -1387,6 +1450,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
validateParentGroupRulesBeforeClientActivation(client);
this.clientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(client);
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
@@ -1394,6 +1458,12 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
runEntityDatatableCheck(clientId, client.getLegalForm());
final AppUser currentUser = this.context.authenticatedUser();
// Four-eyes principle: whoever created this client (the maker) cannot also be the one
// who activates it (the checker) — no bypass, including for CHECKER_SUPER_USER, since
// that would recreate the exact "role bypasses the hold" loophole this closes.
if (client.getCreatedBy().isPresent() && client.getCreatedBy().get().equals(currentUser.getId())) {
throw new ClientActivationSelfApprovalNotAllowedException();
}
client.activate(currentUser, fmt, activationDate);
CommandProcessingResult result = openSavingsAccount(client, fmt);
clientRepository.saveAndFlush(client);
@@ -1786,6 +1856,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
* verifying KYC names against ID documents). Null / blank → null so we don't accidentally
* persist an empty string in place of an unset field.
*/
@SuppressWarnings("StringSplitter")
private static String toTitleCase(final String raw) {
if (raw == null) {
return null;
@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.util.List;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
public interface KycRiskRulesReadPlatformService {
List<KycRiskRuleData> retrieveAllRules();
}
@@ -0,0 +1,68 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService {
private final ClientRiskRuleRepository repository;
@Override
public List<KycRiskRuleData> retrieveAllRules() {
final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition();
return rules.stream().map(this::mapToData).collect(Collectors.toList());
}
private KycRiskRuleData mapToData(final ClientRiskRule rule) {
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null;
final String occName = rule.getOccupation() != null ? rule.getOccupation().getLabel() : null;
return new KycRiskRuleData(
rule.getId(),
rule.getRuleName(),
rule.getLegalFormId(),
occCatId,
occCatName,
occId,
occName,
rule.getMinMonthlyIncome(),
rule.getMaxMonthlyIncome(),
rule.getMinAnnualIncome(),
rule.getMaxAnnualIncome(),
rule.getMinAnnualTurnover(),
rule.getMaxAnnualTurnover(),
rule.getRiskCategory(),
rule.getReason(),
rule.getOrderPosition()
);
}
}

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